diff --git a/src/PowderCoating.Application/DTOs/AI/AiQuickQuoteDtos.cs b/src/PowderCoating.Application/DTOs/AI/AiQuickQuoteDtos.cs
new file mode 100644
index 0000000..2166558
--- /dev/null
+++ b/src/PowderCoating.Application/DTOs/AI/AiQuickQuoteDtos.cs
@@ -0,0 +1,83 @@
+namespace PowderCoating.Application.DTOs.AI;
+
+/// Request from the Quick Quote widget to analyze a verbal/phone description.
+public class AiQuickQuoteRequest
+{
+ public string Description { get; set; } = string.Empty;
+ public int Quantity { get; set; } = 1;
+ public int CoatCount { get; set; } = 1;
+ public int CompanyId { get; set; }
+}
+
+/// Result returned to the Quick Quote widget after AI analysis.
+public class AiQuickQuoteResult
+{
+ public bool Success { get; set; }
+ public string? ErrorMessage { get; set; }
+
+ public string Description { get; set; } = string.Empty;
+ public decimal SurfaceAreaSqFt { get; set; }
+ public string Complexity { get; set; } = "Moderate";
+ public int EstimatedMinutes { get; set; }
+ public bool RequiresPreheat { get; set; }
+ public int PreheatMinutes { get; set; }
+ public string Confidence { get; set; } = "Medium";
+ public string? Reasoning { get; set; }
+
+ ///
+ /// Detected color names from the description with server-resolved inventory stock status.
+ /// Populated by the controller after the AI call — the service populates DetectedColorName only.
+ ///
+ public List PowderMatches { get; set; } = new();
+
+ public decimal EstimatedUnitPrice { get; set; }
+ public decimal EstimatedTotal { get; set; }
+ public AiPricingBreakdown? Breakdown { get; set; }
+}
+
+/// Inventory stock result for a powder color the customer mentioned.
+public class PowderStockMatch
+{
+ /// Color name exactly as extracted by Claude from the customer description.
+ public string DetectedColorName { get; set; } = string.Empty;
+ /// Matched inventory item display name; null when no inventory match was found.
+ public string? InventoryItemName { get; set; }
+ public decimal QuantityOnHand { get; set; }
+ public decimal UnitCost { get; set; }
+ public bool IsInStock { get; set; }
+ public bool HasInventoryMatch { get; set; }
+}
+
+/// Request to persist the quick quote estimate as a draft Quote record.
+public class SaveQuickQuoteRequest
+{
+ /// Caller identifier — used as the quote CustomerPO (e.g., "John - 4 wheels").
+ public string Reference { get; set; } = string.Empty;
+ public string OriginalDescription { get; set; } = string.Empty;
+ public string AiDescription { get; set; } = string.Empty;
+ public decimal SurfaceAreaSqFt { get; set; }
+ public string Complexity { get; set; } = "Moderate";
+ public int EstimatedMinutes { get; set; }
+ public bool RequiresPreheat { get; set; }
+ public int PreheatMinutes { get; set; }
+ public int Quantity { get; set; } = 1;
+ public int CoatCount { get; set; } = 1;
+ public decimal EstimatedUnitPrice { get; set; }
+ public decimal MaterialCost { get; set; }
+ public decimal LaborCost { get; set; }
+}
+
+/// Internal JSON schema returned by Claude for quick quote analysis.
+public class ClaudeQuickQuoteResponse
+{
+ public string Description { get; set; } = string.Empty;
+ public decimal SurfaceAreaSqFt { get; set; }
+ public string Complexity { get; set; } = "Moderate";
+ public int EstimatedMinutes { get; set; }
+ public bool RequiresPreheat { get; set; }
+ public int PreheatMinutes { get; set; }
+ public string Confidence { get; set; } = "Medium";
+ public string Reasoning { get; set; } = string.Empty;
+ /// Color/powder names verbatim from the customer description — server resolves inventory stock.
+ public List DetectedColorNames { get; set; } = new();
+}
diff --git a/src/PowderCoating.Application/Interfaces/IAiQuickQuoteService.cs b/src/PowderCoating.Application/Interfaces/IAiQuickQuoteService.cs
new file mode 100644
index 0000000..062233d
--- /dev/null
+++ b/src/PowderCoating.Application/Interfaces/IAiQuickQuoteService.cs
@@ -0,0 +1,18 @@
+using PowderCoating.Application.DTOs.AI;
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Application.Interfaces;
+
+public interface IAiQuickQuoteService
+{
+ ///
+ /// Analyze a verbal/phone description and return a quick pricing estimate.
+ /// Color name extraction is included in the result; inventory stock resolution
+ /// is performed by the caller so the prompt stays lean.
+ ///
+ Task AnalyzeAsync(
+ AiQuickQuoteRequest request,
+ CompanyOperatingCosts costs,
+ decimal avgPowderCostPerLb,
+ CompanyAiContext? context = null);
+}
diff --git a/src/PowderCoating.Application/Mappings/CatalogProfile.cs b/src/PowderCoating.Application/Mappings/CatalogProfile.cs
index fa034d7..eaacd5a 100644
--- a/src/PowderCoating.Application/Mappings/CatalogProfile.cs
+++ b/src/PowderCoating.Application/Mappings/CatalogProfile.cs
@@ -37,6 +37,9 @@ namespace PowderCoating.Application.Mappings
.ForMember(dest => dest.SubCategories, opt => opt.Ignore())
.ForMember(dest => dest.Items, opt => opt.Ignore());
+ // CatalogCategory -> UpdateCategoryDto (reverse mapping for Edit GET)
+ CreateMap();
+
// UpdateCategoryDto -> CatalogCategory
CreateMap()
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
diff --git a/src/PowderCoating.Infrastructure/Services/AiQuickQuoteService.cs b/src/PowderCoating.Infrastructure/Services/AiQuickQuoteService.cs
new file mode 100644
index 0000000..23bdf40
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Services/AiQuickQuoteService.cs
@@ -0,0 +1,290 @@
+using System.Text;
+using System.Text.Json;
+using Anthropic.SDK;
+using Anthropic.SDK.Messaging;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using PowderCoating.Application.DTOs.AI;
+using PowderCoating.Application.Interfaces;
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Infrastructure.Services;
+
+public class AiQuickQuoteService : IAiQuickQuoteService
+{
+ private readonly IConfiguration _config;
+ private readonly ILogger _logger;
+
+ ///
+ /// Lean system prompt focused on verbal/phone descriptions.
+ /// Intentionally shorter than the photo-analysis prompt — no image guidance needed.
+ /// The detectedColorNames field lets Claude extract powder names so the server can
+ /// resolve inventory stock without sending the full inventory to the model.
+ ///
+ private const string SystemPrompt = @"You are an expert powder coating estimator. A customer is describing items over the phone or at the shop counter.
+
+Based on the verbal description, respond ONLY with a valid JSON object — no markdown, no explanation:
+
+{
+ ""description"": ""string - concise item name (e.g., 'Steel bracket set', 'Aluminum wheel rims x4')"",
+ ""surfaceAreaSqFt"": number - estimated surface area per single item in square feet,
+ ""complexity"": ""Simple"" | ""Moderate"" | ""Complex"" | ""Extreme"",
+ ""estimatedMinutes"": number - estimated ACTIVE LABOR time in minutes per item (blasting, masking, application, inspection — NOT oven cure time),
+ ""requiresPreheat"": boolean - true for cast iron, cast aluminum, galvanized steel, wrought iron,
+ ""preheatMinutes"": number - 0 if requiresPreheat is false; typical: cast iron 45-60, cast aluminum 30-45, galvanized 30-45,
+ ""confidence"": ""Low"" | ""Medium"" | ""High"",
+ ""reasoning"": ""string - one sentence explaining key assumptions made"",
+ ""detectedColorNames"": [""string""] - color or powder finish names mentioned by the customer verbatim (e.g., [""Matte Black"", ""Alien Silver""]); empty array if none mentioned
+}
+
+Complexity guide:
+- Simple: flat panels, basic shapes, minimal masking
+- Moderate: moderate curves, some recesses, standard masking
+- Complex: intricate geometry, deep recesses, welded assemblies, significant masking
+- Extreme: highly ornate, deep cavities, heavy prep and masking required
+
+When estimating from a verbal description:
+- Default to steel if material is not stated
+- Use common reference sizes (car wheel ~1.5-2.5 sqft, motorcycle frame ~8-12 sqft, door ~20 sqft, railing section ~4-6 sqft)
+- Set confidence to ""Low"" when dimensions are missing or vague
+- Never ask follow-up questions — return your best estimate with Low confidence instead";
+
+ public AiQuickQuoteService(IConfiguration config, ILogger logger)
+ {
+ _config = config;
+ _logger = logger;
+ }
+
+ ///
+ public async Task AnalyzeAsync(
+ AiQuickQuoteRequest request,
+ CompanyOperatingCosts costs,
+ decimal avgPowderCostPerLb,
+ CompanyAiContext? context = null)
+ {
+ var apiKey = _config["AI:Anthropic:ApiKey"];
+ if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
+ {
+ return new AiQuickQuoteResult
+ {
+ Success = false,
+ ErrorMessage = "Anthropic API key is not configured. Contact your administrator."
+ };
+ }
+
+ try
+ {
+ var client = new AnthropicClient(apiKey);
+
+ var messageRequest = new MessageParameters
+ {
+ Model = "claude-sonnet-4-6",
+ MaxTokens = 512,
+ Temperature = 0.2m,
+ SystemMessage = BuildSystemPrompt(context),
+ Messages = new List
+ {
+ new Message
+ {
+ Role = RoleType.User,
+ Content = new List
+ {
+ new TextContent { Text = BuildUserPrompt(request) }
+ }
+ }
+ }
+ };
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ var response = await client.Messages.GetClaudeMessageAsync(messageRequest, cts.Token);
+ var rawText = response.FirstMessage?.Text
+ ?? response.Content.OfType().FirstOrDefault()?.Text
+ ?? "";
+
+ _logger.LogInformation("Claude quick quote response: {Response}",
+ rawText.Length > 300 ? rawText[..300] : rawText);
+
+ var claudeResult = ParseResponse(rawText);
+ if (claudeResult == null)
+ {
+ return new AiQuickQuoteResult
+ {
+ Success = false,
+ ErrorMessage = "AI returned an unexpected response format. Please try again."
+ };
+ }
+
+ var (unitPrice, total, breakdown) = CalculatePricing(claudeResult, request, costs, avgPowderCostPerLb);
+
+ return new AiQuickQuoteResult
+ {
+ Success = true,
+ Description = claudeResult.Description,
+ SurfaceAreaSqFt = claudeResult.SurfaceAreaSqFt,
+ Complexity = claudeResult.Complexity,
+ EstimatedMinutes = claudeResult.EstimatedMinutes,
+ RequiresPreheat = claudeResult.RequiresPreheat,
+ PreheatMinutes = claudeResult.PreheatMinutes,
+ Confidence = claudeResult.Confidence,
+ Reasoning = claudeResult.Reasoning,
+ // Controller fills in stock status for each entry after an inventory lookup
+ PowderMatches = claudeResult.DetectedColorNames
+ .Select(n => new PowderStockMatch { DetectedColorName = n })
+ .ToList(),
+ EstimatedUnitPrice = unitPrice,
+ EstimatedTotal = total,
+ Breakdown = breakdown
+ };
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogWarning("Quick quote AI request timed out after 30 s");
+ return new AiQuickQuoteResult { Success = false, ErrorMessage = "Request timed out. Please try again." };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Quick quote AI analysis failed");
+ return new AiQuickQuoteResult { Success = false, ErrorMessage = "AI analysis failed. Please try again." };
+ }
+ }
+
+ private static string BuildSystemPrompt(CompanyAiContext? context)
+ {
+ if (context == null ||
+ (string.IsNullOrWhiteSpace(context.ProfileText) && context.AcceptedExamples.Count == 0))
+ return SystemPrompt;
+
+ var sb = new StringBuilder(SystemPrompt);
+
+ if (!string.IsNullOrWhiteSpace(context.ProfileText))
+ {
+ sb.AppendLine();
+ sb.AppendLine();
+ sb.AppendLine("COMPANY-SPECIFIC CONTEXT — use this to calibrate estimates for this shop:");
+ sb.AppendLine(context.ProfileText.Trim());
+ }
+
+ if (context.AcceptedExamples.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine("CALIBRATION EXAMPLES from this shop's accepted quotes:");
+ foreach (var ex in context.AcceptedExamples)
+ sb.AppendLine($"- {ex.Description}: {ex.SurfaceAreaSqFt:F1} sqft, {ex.Complexity}, {ex.EstimatedMinutes} min, ${ex.FinalUnitPrice:F2}/item");
+ }
+
+ return sb.ToString();
+ }
+
+ private static string BuildUserPrompt(AiQuickQuoteRequest request)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine($"Customer description: {request.Description}");
+ sb.AppendLine($"Quantity: {request.Quantity}");
+ sb.AppendLine($"Coat count requested: {request.CoatCount}");
+ return sb.ToString().TrimEnd();
+ }
+
+ private ClaudeQuickQuoteResponse? ParseResponse(string rawText)
+ {
+ try
+ {
+ var json = rawText.Trim();
+ if (json.StartsWith("```"))
+ {
+ var start = json.IndexOf('{');
+ var end = json.LastIndexOf('}');
+ if (start >= 0 && end > start)
+ json = json[start..(end + 1)];
+ }
+
+ return JsonSerializer.Deserialize(json,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to parse Claude quick quote JSON: {Raw}", rawText);
+ return null;
+ }
+ }
+
+ ///
+ /// Server-side pricing using the same math as AiQuoteService.
+ /// Material-type minimum floors are omitted — verbal descriptions rarely include material,
+ /// and the confidence returned by the AI already reflects that uncertainty.
+ ///
+ private static (decimal UnitPrice, decimal Total, AiPricingBreakdown Breakdown) CalculatePricing(
+ ClaudeQuickQuoteResponse ai,
+ AiQuickQuoteRequest request,
+ CompanyOperatingCosts costs,
+ decimal avgPowderCostPerLb)
+ {
+ const decimal defaultCoverage = 30m;
+ const decimal defaultEfficiency = 0.65m;
+
+ var lbsPerCoat = ai.SurfaceAreaSqFt > 0
+ ? ai.SurfaceAreaSqFt / (defaultCoverage * defaultEfficiency)
+ : 0m;
+ var materialCost = lbsPerCoat * request.CoatCount * avgPowderCostPerLb;
+ var consumablesSurcharge = materialCost * 0.05m;
+
+ // System prompt asks Claude for per-item minutes — no division by quantity needed.
+ var perItemMinutes = (decimal)ai.EstimatedMinutes;
+ var laborCost = (perItemMinutes / 60m) * costs.StandardLaborRate;
+
+ var preheatCost = 0m;
+ var preheatMinutes = 0;
+ if (ai.RequiresPreheat && ai.PreheatMinutes > 0)
+ {
+ preheatMinutes = ai.PreheatMinutes;
+ preheatCost = (preheatMinutes / 60m) * costs.OvenOperatingCostPerHour;
+ }
+
+ var materialWithMarkup = (materialCost + consumablesSurcharge) * (1 + costs.GeneralMarkupPercentage / 100m);
+ var subtotalBeforeComplexity = materialWithMarkup + laborCost + preheatCost;
+
+ var complexityPct = ai.Complexity switch
+ {
+ "Simple" => costs.ComplexitySimplePercent / 100m,
+ "Moderate" => costs.ComplexityModeratePercent / 100m,
+ "Complex" => costs.ComplexityComplexPercent / 100m,
+ "Extreme" => costs.ComplexityExtremePercent / 100m,
+ _ => 0m
+ };
+ var complexityCharge = subtotalBeforeComplexity * complexityPct;
+ var subtotal = subtotalBeforeComplexity + complexityCharge;
+
+ if (subtotal < costs.ShopMinimumCharge && costs.ShopMinimumCharge > 0)
+ subtotal = costs.ShopMinimumCharge;
+
+ var unitPrice = Math.Max(0, Math.Round(subtotal, 2));
+ var total = unitPrice * request.Quantity;
+ var markupAmount = (materialCost + consumablesSurcharge) * (costs.GeneralMarkupPercentage / 100m);
+ var ovenCycleMinutes = costs.DefaultOvenCycleMinutes > 0 ? costs.DefaultOvenCycleMinutes : 45;
+
+ var breakdown = new AiPricingBreakdown
+ {
+ SurfaceAreaSqFt = Math.Round(ai.SurfaceAreaSqFt, 2),
+ PowderLbsPerCoat = Math.Round(lbsPerCoat, 3),
+ CoatCount = request.CoatCount,
+ MaterialCost = Math.Round(materialCost, 2),
+ ConsumablesCost = Math.Round(consumablesSurcharge, 2),
+ EstimatedMinutes = ai.EstimatedMinutes,
+ MaterialMinMinutes = 0,
+ MinFloorApplied = false,
+ LaborCost = Math.Round(laborCost, 2),
+ OvenCycleMinutes = ovenCycleMinutes,
+ OvenCost = 0m,
+ RequiresPreheat = ai.RequiresPreheat,
+ PreheatMinutes = preheatMinutes,
+ PreheatCost = Math.Round(preheatCost, 2),
+ SubtotalBeforeComplexity = Math.Round(subtotalBeforeComplexity, 2),
+ Complexity = ai.Complexity,
+ ComplexityCharge = Math.Round(complexityCharge, 2),
+ SubtotalBeforeMarkup = Math.Round(subtotalBeforeComplexity, 2),
+ MarkupAmount = Math.Round(markupAmount, 2),
+ UnitPrice = unitPrice
+ };
+
+ return (unitPrice, total, breakdown);
+ }
+}
diff --git a/src/PowderCoating.Web/Controllers/AiQuickQuoteController.cs b/src/PowderCoating.Web/Controllers/AiQuickQuoteController.cs
new file mode 100644
index 0000000..f95a314
--- /dev/null
+++ b/src/PowderCoating.Web/Controllers/AiQuickQuoteController.cs
@@ -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 _userManager;
+ private readonly ILogger _logger;
+
+ public AiQuickQuoteController(
+ IUnitOfWork unitOfWork,
+ IAiQuickQuoteService aiService,
+ IPricingCalculationService pricingService,
+ ApplicationDbContext context,
+ UserManager userManager,
+ ILogger logger)
+ {
+ _unitOfWork = unitOfWork;
+ _aiService = aiService;
+ _pricingService = pricingService;
+ _context = context;
+ _userManager = userManager;
+ _logger = logger;
+ }
+
+ ///
+ /// 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.
+ ///
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
+ public async Task 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ [Authorize(Policy = AppConstants.Policies.CanCreateQuotes)]
+ public async Task 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 ─────────────────────────────────────────────────────────────────
+
+ ///
+ /// Builds company AI context from the operating cost profile and recent accepted predictions,
+ /// mirroring the same pattern used in QuotesController for photo quote analysis.
+ ///
+ private async Task 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ private async Task ResolveInventoryStockAsync(List 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 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);
+ });
+ }
+
+ ///
+ /// 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.
+ ///
+ private async Task 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;
+ }
+
+ ///
+ /// Generates the next sequential quote number in PREFIX-YYMM-#### format.
+ /// Uses IgnoreQueryFilters so soft-deleted quotes are counted, preventing number reuse.
+ ///
+ private async Task 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}";
+ }
+}
diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs
index 8b8ddd1..ac374d8 100644
--- a/src/PowderCoating.Web/Controllers/JobsController.cs
+++ b/src/PowderCoating.Web/Controllers/JobsController.cs
@@ -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
}
}
+ ///
+ /// Reassigns a job to a different customer.
+ ///
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task 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 ────────────────────────────────────────────
///
diff --git a/src/PowderCoating.Web/Controllers/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs
index a8517ed..4896e46 100644
--- a/src/PowderCoating.Web/Controllers/QuotesController.cs
+++ b/src/PowderCoating.Web/Controllers/QuotesController.cs
@@ -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
}
}
+ ///
+ /// Reassigns a quote to a different customer. Clears any prospect fields so the
+ /// quote is treated as a real-customer quote after reassignment.
+ ///
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task 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 });
+ }
+
///
/// Generates and streams the quote PDF.
/// When 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)
diff --git a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs
index e51e029..9f73950 100644
--- a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs
+++ b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs
@@ -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
diff --git a/src/PowderCoating.Web/Program.cs b/src/PowderCoating.Web/Program.cs
index 356706a..c8c8ecf 100644
--- a/src/PowderCoating.Web/Program.cs
+++ b/src/PowderCoating.Web/Program.cs
@@ -194,6 +194,7 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddSingleton();
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/src/PowderCoating.Web/Views/Help/Jobs.cshtml b/src/PowderCoating.Web/Views/Help/Jobs.cshtml
index 50a9d61..15c81af 100644
--- a/src/PowderCoating.Web/Views/Help/Jobs.cshtml
+++ b/src/PowderCoating.Web/Views/Help/Jobs.cshtml
@@ -545,6 +545,37 @@
+
+
+ Changing the Customer
+
+
+ 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:
+
+
+
A job was created under the Walk-In / Phone placeholder and the real customer is added later.
+
A job was accidentally assigned to the wrong customer.
+
A job converted from a quote needs to be moved to a different customer record.
+
+
+
How to change the customer
+
+
Open the job from Operations › Jobs and go to its Details page.
+
Find the Customer field in the job header — it appears as a dropdown showing the current customer.
+
Select a different customer from the dropdown.
+
A confirmation banner appears: "Change customer to [Name]?" — click Save to confirm or Cancel to revert.
+
+
+
+
+ The customer can also be changed on the Edit Job page using the Customer
+ dropdown there. Any invoices or deposits already linked to the job are not automatically
+ moved — update those separately if needed.
+
+
+
+
Blank Work Order
@@ -611,6 +642,7 @@
Shop Display & Priority BoardPart IntakeShop Mobile
+ Changing the CustomerBlank Work Order
diff --git a/src/PowderCoating.Web/Views/Help/Quotes.cshtml b/src/PowderCoating.Web/Views/Help/Quotes.cshtml
index 0e0abaa..e5d2434 100644
--- a/src/PowderCoating.Web/Views/Help/Quotes.cshtml
+++ b/src/PowderCoating.Web/Views/Help/Quotes.cshtml
@@ -343,6 +343,79 @@
+
+
+ AI Quick Quote
+
+
+ The AI Quick Quote 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.
+
+
+
How to use it
+
+
Click the AI Quick Quote floating button (bottom-right, dark blue with a lightning bolt icon).
+
+ Type a description of the work — for example:
+ "4 motorcycle wheels in Alien Silver with a black base coat, need sandblasting"
+
+
Set the Quantity and Number of Coats.
+
Click Get Estimate. 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.
+
+ The panel also shows powder stock status 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.
+
+
If the estimate looks right, enter an optional Customer Reference (e.g., the caller's name) and click Save as Draft Quote. You land directly on the new quote's Details page where you can adjust anything and assign the real customer.
+
+
+
+
+
+ Quick quotes are saved under a "Walk-In / Phone" 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 Changing the Customer below.
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+ Changing the Customer
+
+
+ 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:
+
+
+
A quote was saved under the Walk-In / Phone placeholder and the real customer record is created later.
+
A quote was accidentally assigned to the wrong customer.
+
A prospect quote needs to be reassigned after the prospect becomes a customer.
+
+
+
How to change the customer
+
+
Open the quote from Operations › Quotes and go to its Details page.
+
Find the Customer field in the quote header — it appears as a dropdown showing the current customer.
+
Select a different customer from the dropdown.
+
A confirmation banner appears: "Change customer to [Name]?" — click Save to confirm or Cancel to revert to the original.
+
+
+
+
+ The customer can also be changed on the Edit Quote page using the Customer
+ dropdown there. If the quote was originally for a prospect, switching to a customer record
+ automatically clears the prospect fields.
+
+
+
+
Understanding the Pricing Breakdown
@@ -415,6 +488,8 @@
Converting a ProspectApproval PortalDeposits
+ AI Quick Quote
+ Changing the CustomerPricing Breakdown
diff --git a/src/PowderCoating.Web/Views/Jobs/Details.cshtml b/src/PowderCoating.Web/Views/Jobs/Details.cshtml
index 854d6de..aa953f1 100644
--- a/src/PowderCoating.Web/Views/Jobs/Details.cshtml
+++ b/src/PowderCoating.Web/Views/Jobs/Details.cshtml
@@ -56,23 +56,22 @@