Add AI Quick Quote widget and inline customer reassignment
- New AI Quick Quote floating button: staff type a verbal description to get an instant price estimate for phone/walk-in customers; detected color names are fuzzy-matched against inventory for stock status; saves draft quote under a Walk-In / Phone customer with one click - Inline customer change on Quote Details and Job Details: always-visible native select with inline confirmation banner (no TomSelect dependency); ChangeCustomer AJAX endpoints on QuotesController and JobsController - Quote Edit page: customer dropdown is now editable (lock removed) - Fix AutoMapper missing CatalogCategory -> UpdateCategoryDto mapping that caused a crash on the catalog category Edit page - Help docs and AI knowledge base updated for all three features Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
namespace PowderCoating.Application.DTOs.AI;
|
||||
|
||||
/// <summary>Request from the Quick Quote widget to analyze a verbal/phone description.</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>Result returned to the Quick Quote widget after AI analysis.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public List<PowderStockMatch> PowderMatches { get; set; } = new();
|
||||
|
||||
public decimal EstimatedUnitPrice { get; set; }
|
||||
public decimal EstimatedTotal { get; set; }
|
||||
public AiPricingBreakdown? Breakdown { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Inventory stock result for a powder color the customer mentioned.</summary>
|
||||
public class PowderStockMatch
|
||||
{
|
||||
/// <summary>Color name exactly as extracted by Claude from the customer description.</summary>
|
||||
public string DetectedColorName { get; set; } = string.Empty;
|
||||
/// <summary>Matched inventory item display name; null when no inventory match was found.</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>Request to persist the quick quote estimate as a draft Quote record.</summary>
|
||||
public class SaveQuickQuoteRequest
|
||||
{
|
||||
/// <summary>Caller identifier — used as the quote CustomerPO (e.g., "John - 4 wheels").</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema returned by Claude for quick quote analysis.</summary>
|
||||
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;
|
||||
/// <summary>Color/powder names verbatim from the customer description — server resolves inventory stock.</summary>
|
||||
public List<string> DetectedColorNames { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using PowderCoating.Application.DTOs.AI;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
public interface IAiQuickQuoteService
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<AiQuickQuoteResult> AnalyzeAsync(
|
||||
AiQuickQuoteRequest request,
|
||||
CompanyOperatingCosts costs,
|
||||
decimal avgPowderCostPerLb,
|
||||
CompanyAiContext? context = null);
|
||||
}
|
||||
@@ -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<CatalogCategory, UpdateCategoryDto>();
|
||||
|
||||
// UpdateCategoryDto -> CatalogCategory
|
||||
CreateMap<UpdateCategoryDto, CatalogCategory>()
|
||||
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
||||
|
||||
@@ -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<AiQuickQuoteService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<AiQuickQuoteService> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AiQuickQuoteResult> 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<Message>
|
||||
{
|
||||
new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase>
|
||||
{
|
||||
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<TextContent>().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<ClaudeQuickQuoteResponse>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse Claude quick quote JSON: {Raw}", rawText);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 › 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 & 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 › 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>
|
||||
|
||||
@@ -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>
|
||||
@Html.AntiForgeryToken()
|
||||
<strong>Customer:</strong>
|
||||
<a asp-controller="Customers" asp-action="Details" asp-route-id="@Model.CustomerId">
|
||||
@Model.CustomerName
|
||||
</a>
|
||||
</p>
|
||||
<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
|
||||
|
||||
@@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[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(); });
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user