Compare commits
6 Commits
fc9ddc6d17
...
931d6d40da
| Author | SHA1 | Date | |
|---|---|---|---|
| 931d6d40da | |||
| 3327c86909 | |||
| 27ac793f62 | |||
| 4153acf3aa | |||
| 813f76138c | |||
| 8d94013895 |
+12
-6
@@ -68,24 +68,30 @@ pipeline {
|
||||
powershell '''
|
||||
$path = "C:\\inetpub\\wwwroot\\web.config"
|
||||
$xml = [xml](Get-Content $path)
|
||||
$aspNetCore = $xml.configuration.location.'system.webServer'.aspNetCore
|
||||
|
||||
# Use XPath so the structure (with or without <location> wrapper) doesn't matter
|
||||
$aspNetCore = $xml.SelectSingleNode("//aspNetCore")
|
||||
if ($null -eq $aspNetCore) {
|
||||
Write-Error "Could not find aspNetCore element in web.config"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Ensure environmentVariables element exists
|
||||
if (-not $aspNetCore.environmentVariables) {
|
||||
$envVarsNode = $aspNetCore.SelectSingleNode("environmentVariables")
|
||||
if ($null -eq $envVarsNode) {
|
||||
$envVarsNode = $xml.CreateElement("environmentVariables")
|
||||
$aspNetCore.AppendChild($envVarsNode) | Out-Null
|
||||
}
|
||||
|
||||
# Remove existing ASPNETCORE_ENVIRONMENT entry if present
|
||||
$existing = $aspNetCore.environmentVariables.environmentVariable |
|
||||
Where-Object { $_.name -eq "ASPNETCORE_ENVIRONMENT" }
|
||||
if ($existing) { $aspNetCore.environmentVariables.RemoveChild($existing) | Out-Null }
|
||||
$existing = $envVarsNode.SelectSingleNode("environmentVariable[@name='ASPNETCORE_ENVIRONMENT']")
|
||||
if ($existing) { $envVarsNode.RemoveChild($existing) | Out-Null }
|
||||
|
||||
# Add fresh entry
|
||||
$envVar = $xml.CreateElement("environmentVariable")
|
||||
$envVar.SetAttribute("name", "ASPNETCORE_ENVIRONMENT")
|
||||
$envVar.SetAttribute("value", "Development")
|
||||
$aspNetCore.environmentVariables.AppendChild($envVar) | Out-Null
|
||||
$envVarsNode.AppendChild($envVar) | Out-Null
|
||||
|
||||
$xml.Save($path)
|
||||
Write-Host "web.config patched: ASPNETCORE_ENVIRONMENT=Development"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -140,6 +140,14 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
public decimal DerivedBlastRateSqFtPerHour { get; set; }
|
||||
/// <summary>Derived coating rate — shown to the user as a sanity-check value.</summary>
|
||||
public decimal DerivedCoatingRateSqFtPerHour { get; set; }
|
||||
|
||||
// Facility Overhead
|
||||
public decimal MonthlyRent { get; set; }
|
||||
public decimal MonthlyUtilities { get; set; }
|
||||
public int MonthlyBillableHours { get; set; } = 160;
|
||||
|
||||
/// <summary>Derived facility overhead rate = (MonthlyRent + MonthlyUtilities) / MonthlyBillableHours.</summary>
|
||||
public decimal FacilityOverheadRatePerHour { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -228,6 +236,19 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
|
||||
[Range(0, 500)]
|
||||
public decimal ComplexityExtremePercent { get; set; } = 25m;
|
||||
|
||||
// Facility Overhead
|
||||
[Range(0, 1000000, ErrorMessage = "Monthly rent must be between 0 and 1,000,000")]
|
||||
[Display(Name = "Monthly Rent ($)")]
|
||||
public decimal MonthlyRent { get; set; } = 0m;
|
||||
|
||||
[Range(0, 1000000, ErrorMessage = "Monthly utilities must be between 0 and 1,000,000")]
|
||||
[Display(Name = "Monthly Utilities ($)")]
|
||||
public decimal MonthlyUtilities { get; set; } = 0m;
|
||||
|
||||
[Range(1, 10000, ErrorMessage = "Billable hours must be between 1 and 10,000")]
|
||||
[Display(Name = "Billable Hours/Month")]
|
||||
public int MonthlyBillableHours { get; set; } = 160;
|
||||
}
|
||||
|
||||
/// <summary>DTO for updating the company AI profile text used for AI Photo Quote calibration.</summary>
|
||||
|
||||
@@ -606,6 +606,9 @@ public class QuotePricingBreakdownDto
|
||||
public int OvenBatches { get; set; }
|
||||
public int OvenCycleMinutes { get; set; }
|
||||
|
||||
public decimal FacilityOverheadCost { get; set; }
|
||||
public decimal FacilityOverheadRatePerHour { get; set; }
|
||||
|
||||
public decimal Total { get; set; }
|
||||
|
||||
// Cost Breakdown Details
|
||||
@@ -822,6 +825,10 @@ public class QuotePricingResult
|
||||
public int OvenBatches { get; set; }
|
||||
public int OvenCycleMinutes { get; set; }
|
||||
|
||||
// Facility overhead (rent + utilities apportioned by estimated job hours)
|
||||
public decimal FacilityOverheadCost { get; set; }
|
||||
public decimal FacilityOverheadRatePerHour { get; set; }
|
||||
|
||||
// Detailed breakdown for transparency
|
||||
public decimal MaterialCosts { get; set; }
|
||||
public decimal LaborCosts { get; set; }
|
||||
|
||||
@@ -112,6 +112,19 @@ public class WizardStep2Dto
|
||||
|
||||
[Display(Name = "Shop Capability Tier")]
|
||||
public ShopCapabilityTier ShopCapabilityTier { get; set; } = ShopCapabilityTier.Small;
|
||||
|
||||
// Facility Overhead
|
||||
[Range(0, 1000000)]
|
||||
[Display(Name = "Monthly Rent ($)")]
|
||||
public decimal MonthlyRent { get; set; } = 0m;
|
||||
|
||||
[Range(0, 1000000)]
|
||||
[Display(Name = "Monthly Utilities ($)")]
|
||||
public decimal MonthlyUtilities { get; set; } = 0m;
|
||||
|
||||
[Range(1, 10000)]
|
||||
[Display(Name = "Billable Hours/Month")]
|
||||
public int MonthlyBillableHours { get; set; } = 160;
|
||||
}
|
||||
|
||||
// ─── Step 3: Branding & Numbering ───────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ public interface IStripeService
|
||||
{
|
||||
Task<string> CreateCheckoutSessionAsync(int companyId, int newPlan, bool isAnnual, string successUrl, string cancelUrl);
|
||||
Task<string> CreateRegistrationCheckoutSessionAsync(int plan, bool isAnnual, string email, string companyName, string successUrl, string cancelUrl);
|
||||
Task<bool> IsRegistrationCheckoutPaidAsync(string sessionId);
|
||||
Task FulfillCheckoutAsync(string sessionId);
|
||||
Task FulfillRegistrationCheckoutAsync(string sessionId, int companyId, int plan);
|
||||
Task SyncSubscriptionAsync(int companyId);
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -37,7 +37,11 @@ public class CompanyProfile : Profile
|
||||
.ForMember(dest => dest.DerivedBlastRateSqFtPerHour,
|
||||
opt => opt.MapFrom(src => ShopCapabilityCalculator.GetBlastRateSqFtPerHour(src)))
|
||||
.ForMember(dest => dest.DerivedCoatingRateSqFtPerHour,
|
||||
opt => opt.MapFrom(src => ShopCapabilityCalculator.GetCoatingRateSqFtPerHour(src)));
|
||||
opt => opt.MapFrom(src => ShopCapabilityCalculator.GetCoatingRateSqFtPerHour(src)))
|
||||
.ForMember(dest => dest.FacilityOverheadRatePerHour,
|
||||
opt => opt.MapFrom(src => src.MonthlyBillableHours > 0
|
||||
? (src.MonthlyRent + src.MonthlyUtilities) / src.MonthlyBillableHours
|
||||
: 0m));
|
||||
CreateMap<UpdateOperatingCostsDto, CompanyOperatingCosts>();
|
||||
CreateMap<UpdateBlastProfileDto, CompanyOperatingCosts>();
|
||||
|
||||
|
||||
@@ -542,6 +542,8 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
ItemsSubtotal = 0,
|
||||
ShopSuppliesAmount = 0,
|
||||
ShopSuppliesPercent = 0,
|
||||
FacilityOverheadCost = 0,
|
||||
FacilityOverheadRatePerHour = 0,
|
||||
OverheadCosts = 0,
|
||||
OverheadPercent = 0,
|
||||
ProfitMargin = 0,
|
||||
@@ -683,14 +685,27 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
|
||||
var itemsAndOvenSubtotal = itemsSubtotal + ovenBatchCost;
|
||||
|
||||
// 5. SHOP SUPPLIES (percentage of items + oven subtotal)
|
||||
// 5. FACILITY OVERHEAD (rent + utilities apportioned by estimated job hours)
|
||||
var facilityOverheadRatePerHour = 0m;
|
||||
var facilityOverheadCost = 0m;
|
||||
if (costs.MonthlyBillableHours > 0 && (costs.MonthlyRent + costs.MonthlyUtilities) > 0)
|
||||
{
|
||||
facilityOverheadRatePerHour = (costs.MonthlyRent + costs.MonthlyUtilities) / costs.MonthlyBillableHours;
|
||||
var totalEstimatedMinutes = items.Sum(i => (decimal)i.EstimatedMinutes * i.Quantity);
|
||||
facilityOverheadCost = facilityOverheadRatePerHour * (totalEstimatedMinutes / 60m);
|
||||
_logger.LogInformation(
|
||||
"Facility overhead: ${Rate:F2}/hr × {Min:F0} min = ${Cost:F2}",
|
||||
facilityOverheadRatePerHour, totalEstimatedMinutes, facilityOverheadCost);
|
||||
}
|
||||
|
||||
// 6. SHOP SUPPLIES (percentage of items + oven subtotal — does not include facility overhead)
|
||||
var shopSuppliesPercent = costs.ShopSuppliesRate;
|
||||
var shopSuppliesAmount = itemsAndOvenSubtotal * (shopSuppliesPercent / 100m);
|
||||
|
||||
// 6. SUBTOTAL BEFORE DISCOUNT (items + oven + shop supplies)
|
||||
var subtotalBeforeDiscount = itemsAndOvenSubtotal + shopSuppliesAmount;
|
||||
// 7. SUBTOTAL BEFORE DISCOUNT (items + oven + facility overhead + shop supplies)
|
||||
var subtotalBeforeDiscount = itemsAndOvenSubtotal + facilityOverheadCost + shopSuppliesAmount;
|
||||
|
||||
// 7. CUSTOMER PRICING TIER DISCOUNT (if applicable)
|
||||
// 8. CUSTOMER PRICING TIER DISCOUNT (if applicable)
|
||||
var pricingTierDiscountPercent = 0m;
|
||||
var pricingTierDiscountAmount = 0m;
|
||||
|
||||
@@ -782,6 +797,8 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
OvenBatchCost = Math.Round(ovenBatchCost, 2),
|
||||
OvenBatches = effectiveBatches,
|
||||
OvenCycleMinutes = effectiveCycleMinutes,
|
||||
FacilityOverheadCost = Math.Round(facilityOverheadCost, 2),
|
||||
FacilityOverheadRatePerHour = Math.Round(facilityOverheadRatePerHour, 4),
|
||||
ShopSuppliesAmount = Math.Round(shopSuppliesAmount, 2),
|
||||
ShopSuppliesPercent = Math.Round(shopSuppliesPercent, 2),
|
||||
OverheadCosts = Math.Round(overheadCosts, 2),
|
||||
|
||||
@@ -127,5 +127,24 @@ namespace PowderCoating.Core.Entities
|
||||
/// </summary>
|
||||
[Range(0, 5000)]
|
||||
public decimal? CoatingRateSqFtPerHourOverride { get; set; }
|
||||
|
||||
// ── Facility Overhead ─────────────────────────────────────────────────────
|
||||
// Monthly fixed costs divided by billable hours to derive a per-hour overhead
|
||||
// rate, which is then applied to each quote based on estimated labor time.
|
||||
|
||||
/// <summary>Monthly shop lease / rent payment.</summary>
|
||||
[Range(0, 1000000)]
|
||||
public decimal MonthlyRent { get; set; } = 0m;
|
||||
|
||||
/// <summary>Monthly utilities combined (electricity, gas, water, internet).</summary>
|
||||
[Range(0, 1000000)]
|
||||
public decimal MonthlyUtilities { get; set; } = 0m;
|
||||
|
||||
/// <summary>
|
||||
/// Estimated billable shop hours per month used to amortise facility overhead.
|
||||
/// Defaults to 160 (4 weeks × 40 hrs). Must be at least 1 to avoid division by zero.
|
||||
/// </summary>
|
||||
[Range(1, 10000)]
|
||||
public int MonthlyBillableHours { get; set; } = 160;
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+9191
File diff suppressed because it is too large
Load Diff
+94
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFacilityOverheadFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MonthlyBillableHours",
|
||||
table: "CompanyOperatingCosts",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 160);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "MonthlyRent",
|
||||
table: "CompanyOperatingCosts",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "MonthlyUtilities",
|
||||
table: "CompanyOperatingCosts",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7155));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7162));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7164));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MonthlyBillableHours",
|
||||
table: "CompanyOperatingCosts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MonthlyRent",
|
||||
table: "CompanyOperatingCosts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MonthlyUtilities",
|
||||
table: "CompanyOperatingCosts");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 23, 1, 54, 43, 181, DateTimeKind.Utc).AddTicks(5272));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 23, 1, 54, 43, 181, DateTimeKind.Utc).AddTicks(5281));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 23, 1, 54, 43, 181, DateTimeKind.Utc).AddTicks(5283));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1770,6 +1770,15 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int>("MonthlyBillableHours")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("MonthlyRent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("MonthlyUtilities")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("OvenOperatingCostPerHour")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -5767,7 +5776,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 23, 1, 54, 43, 181, DateTimeKind.Utc).AddTicks(5272),
|
||||
CreatedAt = new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7155),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -5778,7 +5787,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 23, 1, 54, 43, 181, DateTimeKind.Utc).AddTicks(5281),
|
||||
CreatedAt = new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7162),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -5789,7 +5798,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 23, 1, 54, 43, 181, DateTimeKind.Utc).AddTicks(5283),
|
||||
CreatedAt = new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7164),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,49 @@ public class StripeService : IStripeService
|
||||
return session.Url;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the supplied Stripe Checkout session belongs to the registration flow and has
|
||||
/// reached the paid/complete state. Returns <c>false</c> for any missing/invalid/unpaid session
|
||||
/// so the caller can safely stop before creating any local company or user records.
|
||||
/// </summary>
|
||||
public async Task<bool> IsRegistrationCheckoutPaidAsync(string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sessionService = new SessionService();
|
||||
var session = await sessionService.GetAsync(sessionId);
|
||||
|
||||
if (!session.Metadata.TryGetValue("registration", out var isRegistration) ||
|
||||
!string.Equals(isRegistration, "true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Registration checkout validation failed for session {SessionId}: missing registration metadata",
|
||||
sessionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var isPaidAndComplete = session.PaymentStatus == "paid" && session.Status == "complete";
|
||||
if (!isPaidAndComplete)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Registration checkout validation failed for session {SessionId}: paymentStatus={PaymentStatus}, status={Status}",
|
||||
sessionId, session.PaymentStatus, session.Status);
|
||||
}
|
||||
|
||||
return isPaidAndComplete;
|
||||
}
|
||||
catch (StripeException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Stripe rejected registration checkout validation for session {SessionId}", sessionId);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error validating registration checkout session {SessionId}", sessionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finalizes a registration checkout after payment is confirmed. Called from the
|
||||
/// registration success redirect (not the webhook path). Retrieves the session from Stripe
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -620,6 +620,147 @@ public class CompanySettingsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a suggested AI profile draft from existing company configuration — company name/location,
|
||||
/// named ovens, sandblasting capability, shop worker roles, coating inventory categories, and
|
||||
/// operating cost rates. Returns a pre-filled paragraph the user can review and edit before saving.
|
||||
/// </summary>
|
||||
// GET: CompanySettings/GenerateAiProfileDraft
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GenerateAiProfileDraft()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null)
|
||||
return Json(new { success = false, message = "No company found." });
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.OperatingCosts);
|
||||
if (company == null)
|
||||
return Json(new { success = false, message = "Company not found." });
|
||||
|
||||
var costs = company.OperatingCosts;
|
||||
|
||||
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList();
|
||||
var workers = (await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive)).ToList();
|
||||
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
|
||||
// Opening line
|
||||
var location = new[] { company.City, company.State }.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
var locationStr = string.Join(", ", location);
|
||||
sb.Append(company.CompanyName);
|
||||
if (!string.IsNullOrWhiteSpace(locationStr))
|
||||
sb.Append($" is a powder coating shop based in {locationStr}.");
|
||||
else
|
||||
sb.Append(" is a powder coating shop.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine();
|
||||
|
||||
// Shop size
|
||||
if (costs != null)
|
||||
{
|
||||
var tierLabel = costs.ShopCapabilityTier switch
|
||||
{
|
||||
ShopCapabilityTier.Garage => "garage/hobbyist",
|
||||
ShopCapabilityTier.Small => "small",
|
||||
ShopCapabilityTier.Medium => "medium-sized",
|
||||
ShopCapabilityTier.Large => "high-volume",
|
||||
_ => "small"
|
||||
};
|
||||
sb.AppendLine($"We are a {tierLabel} operation" +
|
||||
(workers.Count > 0 ? $" with {workers.Count} active shop worker{(workers.Count == 1 ? "" : "s")}." : "."));
|
||||
}
|
||||
|
||||
// Ovens
|
||||
if (ovens.Any())
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Our curing ovens:");
|
||||
foreach (var oven in ovens)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (oven.MaxLoadSqFt.HasValue && oven.MaxLoadSqFt > 0)
|
||||
parts.Add($"{oven.MaxLoadSqFt:0} sq ft capacity");
|
||||
if (oven.DefaultCycleMinutes.HasValue && oven.DefaultCycleMinutes > 0)
|
||||
parts.Add($"{oven.DefaultCycleMinutes} min cure cycle");
|
||||
var detail = parts.Any() ? $" ({string.Join(", ", parts)})" : "";
|
||||
sb.AppendLine($"• {oven.Label}{detail}");
|
||||
}
|
||||
}
|
||||
|
||||
// Equipment capabilities inferred from rates
|
||||
if (costs != null)
|
||||
{
|
||||
var capabilities = new List<string>();
|
||||
if (costs.SandblasterCostPerHour > 0)
|
||||
capabilities.Add("sandblasting / media blasting");
|
||||
if (costs.CoatingBoothCostPerHour > 0)
|
||||
capabilities.Add("powder coating booth");
|
||||
if (capabilities.Any())
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"We have in-house {string.Join(" and ", capabilities)} capability.");
|
||||
}
|
||||
}
|
||||
|
||||
// Powder/coating categories
|
||||
if (coatingCategories.Any())
|
||||
{
|
||||
sb.AppendLine();
|
||||
var catNames = coatingCategories.Select(c => c.DisplayName).ToList();
|
||||
sb.AppendLine($"Powder categories we stock: {string.Join(", ", catNames)}.");
|
||||
}
|
||||
|
||||
// Worker roles
|
||||
if (workers.Any())
|
||||
{
|
||||
var roles = workers
|
||||
.Select(w => w.Role)
|
||||
.Distinct()
|
||||
.Select(r => r switch
|
||||
{
|
||||
ShopWorkerRole.Sandblaster => "sandblasting",
|
||||
ShopWorkerRole.Coater => "powder coating",
|
||||
ShopWorkerRole.Masker => "masking",
|
||||
ShopWorkerRole.QualityControl => "quality control",
|
||||
ShopWorkerRole.OvenOperator => "oven operation",
|
||||
ShopWorkerRole.Supervisor => "supervision",
|
||||
ShopWorkerRole.Maintenance => "equipment maintenance",
|
||||
_ => "general labor"
|
||||
})
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (roles.Count > 1)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Staff specialties on hand: {string.Join(", ", roles)}.");
|
||||
}
|
||||
}
|
||||
|
||||
// Rates hint
|
||||
if (costs != null && costs.StandardLaborRate > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Our standard labor rate is ${costs.StandardLaborRate:0.00}/hr. " +
|
||||
$"We target approximately {costs.GeneralMarkupPercentage:0}% markup on all jobs.");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("(Edit this profile to add detail about the types of parts you typically coat, " +
|
||||
"any brands of powder you prefer, your cure temperature, or anything else that " +
|
||||
"helps the AI understand your shop better.)");
|
||||
|
||||
return Json(new { success = true, draft = sb.ToString().Trim() });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating AI profile draft");
|
||||
return Json(new { success = false, message = "An error occurred while generating the draft." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the Quoting Calibration / Shop Capability profile. Maps equipment fields onto
|
||||
/// <see cref="CompanyOperatingCosts"/> and returns the freshly derived blast and coating
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -262,11 +262,10 @@ public class RegistrationController : Controller
|
||||
/// Stripe return URL handler called after the customer completes payment in Stripe Checkout.
|
||||
/// Looks up the <c>PendingRegistrationSession</c> by the opaque <c>reg_token</c> to recover the
|
||||
/// registration data that could not be stored in a session cookie (which may not survive the
|
||||
/// Stripe redirect on some browsers/devices). Marks the session as completed before creating the
|
||||
/// account to prevent duplicate submissions if the user hits reload. Contains a second open-cap
|
||||
/// check in case the tenant limit was reached between the user starting and completing checkout.
|
||||
/// Calls <c>FulfillRegistrationCheckoutAsync</c> to link the Stripe subscription (sets the real
|
||||
/// <c>SubscriptionEndDate</c>); failure is non-fatal — dates can be corrected manually.
|
||||
/// Stripe redirect on some browsers/devices). The Stripe session is re-validated before any
|
||||
/// local company or user is created, and the pending session is only left marked completed once
|
||||
/// registration succeeds. On recoverable failures the completion flag is released so the same
|
||||
/// success URL can be retried instead of forcing manual support intervention.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> PaymentSuccess(string? session_id, string? reg_token)
|
||||
@@ -281,99 +280,123 @@ public class RegistrationController : Controller
|
||||
}
|
||||
|
||||
var pendingSession = await _db.PendingRegistrationSessions
|
||||
.FirstOrDefaultAsync(p => p.Token == reg_token && !p.IsCompleted);
|
||||
.FirstOrDefaultAsync(p => p.Token == reg_token);
|
||||
|
||||
if (pendingSession == null)
|
||||
{
|
||||
TempData["Error"] = "Your registration session was not found or has already been completed. Please fill in your details again.";
|
||||
TempData["Error"] = "Your registration session was not found. Please fill in your details again.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Map DB record to the internal record used below
|
||||
var pending = new PendingRegistration(
|
||||
pendingSession.CompanyName, pendingSession.CompanyPhone,
|
||||
pendingSession.FirstName, pendingSession.LastName,
|
||||
pendingSession.Email, pendingSession.Plan, pendingSession.IsAnnual);
|
||||
|
||||
// Mark completed to prevent duplicate submissions
|
||||
var existingUser = await _userManager.FindByEmailAsync(pending.Email);
|
||||
|
||||
if (pendingSession.IsCompleted)
|
||||
{
|
||||
if (existingUser != null)
|
||||
{
|
||||
await SignInExistingRegistrationUserAsync(existingUser);
|
||||
return RedirectToAction(nameof(Welcome));
|
||||
}
|
||||
|
||||
TempData["Error"] = "Your registration was already submitted, but we couldn't finish signing you in. Please contact support with reference: " + session_id;
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
if (!await _stripeService.IsRegistrationCheckoutPaidAsync(session_id))
|
||||
{
|
||||
TempData["Error"] = "We couldn't verify a completed payment for this registration session yet. Please try the link again in a moment, or contact support if the issue persists.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var keepSessionCompleted = false;
|
||||
pendingSession.IsCompleted = true;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Guard against race condition: re-check capacity after Stripe redirect
|
||||
if (!await IsRegistrationOpenAsync())
|
||||
{
|
||||
TempData["Error"] = "Registration is currently closed. Your payment has been received but no account was created. Please contact support.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Guard against race condition (duplicate submission)
|
||||
if (await _userManager.FindByEmailAsync(pending.Email) != null)
|
||||
{
|
||||
// Account already exists — just sign them in and go to dashboard
|
||||
var existingUser = await _userManager.FindByEmailAsync(pending.Email);
|
||||
if (existingUser != null)
|
||||
{
|
||||
existingUser.LastLoginDate = DateTime.UtcNow;
|
||||
await _userManager.UpdateAsync(existingUser);
|
||||
await _signInManager.SignInAsync(existingUser, isPersistent: false);
|
||||
}
|
||||
return RedirectToAction("Index", "Dashboard");
|
||||
}
|
||||
|
||||
var companyCode = await GenerateUniqueCompanyCodeAsync(pending.CompanyName);
|
||||
var company = new Company
|
||||
{
|
||||
CompanyName = pending.CompanyName,
|
||||
CompanyCode = companyCode,
|
||||
Phone = pending.CompanyPhone,
|
||||
PrimaryContactEmail = pending.Email,
|
||||
PrimaryContactName = $"{pending.FirstName} {pending.LastName}",
|
||||
SubscriptionPlan = pending.Plan,
|
||||
SubscriptionStatus = SubscriptionStatus.Active,
|
||||
SubscriptionStartDate = DateTime.UtcNow,
|
||||
SubscriptionEndDate = DateTime.UtcNow.AddDays(1), // Stripe fulfillment sets real date below
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.Companies.AddAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var tempPassword = GenerateTemporaryPassword();
|
||||
var user = BuildUser(pending.Email, pending.FirstName, pending.LastName, company.Id);
|
||||
|
||||
var createResult = await _userManager.CreateAsync(user, tempPassword);
|
||||
if (!createResult.Succeeded)
|
||||
{
|
||||
await _unitOfWork.Companies.DeleteAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogError("Failed to create user after payment for {Email}: {Errors}",
|
||||
pending.Email, string.Join(", ", createResult.Errors.Select(e => e.Description)));
|
||||
|
||||
TempData["Error"] = "Your payment was received but we encountered an error creating your account. " +
|
||||
"Please contact support with reference: " + session_id;
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Link the Stripe subscription (sets real SubscriptionEndDate)
|
||||
try
|
||||
{
|
||||
await _stripeService.FulfillRegistrationCheckoutAsync(session_id, company.Id, pending.Plan);
|
||||
// Guard against race condition: re-check capacity after Stripe redirect
|
||||
if (!await IsRegistrationOpenAsync())
|
||||
{
|
||||
TempData["Error"] = "Registration is currently closed. Your payment has been received but no account was created. Please contact support.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Recover gracefully if a prior attempt already created the user.
|
||||
if (existingUser != null)
|
||||
{
|
||||
keepSessionCompleted = true;
|
||||
await SignInExistingRegistrationUserAsync(existingUser);
|
||||
return RedirectToAction(nameof(Welcome));
|
||||
}
|
||||
|
||||
var companyCode = await GenerateUniqueCompanyCodeAsync(pending.CompanyName);
|
||||
var company = new Company
|
||||
{
|
||||
CompanyName = pending.CompanyName,
|
||||
CompanyCode = companyCode,
|
||||
Phone = pending.CompanyPhone,
|
||||
PrimaryContactEmail = pending.Email,
|
||||
PrimaryContactName = $"{pending.FirstName} {pending.LastName}",
|
||||
SubscriptionPlan = pending.Plan,
|
||||
SubscriptionStatus = SubscriptionStatus.Active,
|
||||
SubscriptionStartDate = DateTime.UtcNow,
|
||||
SubscriptionEndDate = DateTime.UtcNow.AddDays(1), // Stripe fulfillment sets real date below
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.Companies.AddAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var tempPassword = GenerateTemporaryPassword();
|
||||
var user = BuildUser(pending.Email, pending.FirstName, pending.LastName, company.Id);
|
||||
|
||||
var createResult = await _userManager.CreateAsync(user, tempPassword);
|
||||
if (!createResult.Succeeded)
|
||||
{
|
||||
await _unitOfWork.Companies.DeleteAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogError("Failed to create user after payment for {Email}: {Errors}",
|
||||
pending.Email, string.Join(", ", createResult.Errors.Select(e => e.Description)));
|
||||
|
||||
TempData["Error"] = "Your payment was received, but we couldn't finish creating your account. Please try the success link again, or contact support with reference: " + session_id;
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Link the Stripe subscription (sets real SubscriptionEndDate)
|
||||
try
|
||||
{
|
||||
await _stripeService.FulfillRegistrationCheckoutAsync(session_id, company.Id, pending.Plan);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fulfill registration checkout {SessionId} for company {CompanyId}",
|
||||
session_id, company.Id);
|
||||
// Non-fatal — subscription dates can be synced manually later
|
||||
}
|
||||
|
||||
await _userManager.AddClaimAsync(user, new Claim("MustChangePassword", "true"));
|
||||
await FinalizeRegistrationAsync(user, company, pending.Plan);
|
||||
_ = SendWelcomeEmailAsync(pending.Email, pending.FirstName, tempPassword, pending.Plan,
|
||||
null, $"{Request.Scheme}://{Request.Host}");
|
||||
|
||||
keepSessionCompleted = true;
|
||||
return RedirectToAction(nameof(Welcome));
|
||||
}
|
||||
catch (Exception ex)
|
||||
finally
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fulfill registration checkout {SessionId} for company {CompanyId}",
|
||||
session_id, company.Id);
|
||||
// Non-fatal — subscription dates can be synced manually later
|
||||
if (!keepSessionCompleted)
|
||||
{
|
||||
pendingSession.IsCompleted = false;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
await _userManager.AddClaimAsync(user, new Claim("MustChangePassword", "true"));
|
||||
await FinalizeRegistrationAsync(user, company, pending.Plan);
|
||||
_ = SendWelcomeEmailAsync(pending.Email, pending.FirstName, tempPassword, pending.Plan,
|
||||
null, $"{Request.Scheme}://{Request.Host}");
|
||||
|
||||
return RedirectToAction(nameof(Welcome));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -480,6 +503,18 @@ public class RegistrationController : Controller
|
||||
await _signInManager.SignInAsync(user, isPersistent: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signs in an already-created registration user on idempotent success-link retries.
|
||||
/// This is intentionally narrower than <see cref="FinalizeRegistrationAsync"/> because all
|
||||
/// registration side effects should already have happened on the original successful run.
|
||||
/// </summary>
|
||||
private async Task SignInExistingRegistrationUserAsync(ApplicationUser user)
|
||||
{
|
||||
user.LastLoginDate = DateTime.UtcNow;
|
||||
await _userManager.UpdateAsync(user);
|
||||
await _signInManager.SignInAsync(user, isPersistent: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the post-registration welcome page. Requires authentication (the user was just signed
|
||||
/// in by <see cref="FinalizeRegistrationAsync"/>). Determines whether the account is on a free
|
||||
|
||||
@@ -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.
|
||||
@@ -596,6 +600,7 @@ public static class HelpKnowledgeBase
|
||||
- Standard Labor Rate ($/hr)
|
||||
- Equipment hourly costs (oven, sandblaster, coating booth)
|
||||
- Powder cost per sq ft
|
||||
- **Facility Overhead** — Monthly Rent ($), Monthly Utilities ($), and Monthly Billable Hours. These three fields let the system recover your fixed shop costs on every quote by adding a per-job overhead charge based on estimated labor hours.
|
||||
- General markup %
|
||||
- Tax %
|
||||
- Shop minimum charge
|
||||
@@ -679,6 +684,20 @@ public static class HelpKnowledgeBase
|
||||
A percentage added to cover consumables: masking tape, hooks, racking supplies, touch-up powder, rags, etc. Applied as a percentage of the total material + labor cost.
|
||||
- Typical: 3–8%
|
||||
|
||||
### Facility Overhead (Rent & Utilities)
|
||||
|
||||
**Monthly Rent ($)**
|
||||
Your monthly shop lease or mortgage payment for the production space. Enter 0 if you own outright and have no occupancy cost.
|
||||
|
||||
**Monthly Utilities ($)**
|
||||
Gas, electricity, and water for the shop per month. Do NOT include utility costs already captured in your oven or equipment hourly rates — that would double-count.
|
||||
|
||||
**Monthly Billable Hours (default: 160)**
|
||||
The number of productive hours the shop operates per month. Default of 160 represents one full-time worker-equivalent (4 weeks × 40 hours). If you run two full-time workers, use 320. The system converts rent + utilities into a per-hour rate using: (Monthly Rent + Monthly Utilities) ÷ Monthly Billable Hours.
|
||||
- Example: $2,000 rent + $800 utilities ÷ 160 hours = $17.50/hr overhead rate
|
||||
- That rate × estimated job hours is added as "Facility Overhead" in the quote breakdown
|
||||
- If both rent and utilities are $0, no overhead line is added to quotes
|
||||
|
||||
### Overhead & Profit
|
||||
|
||||
**General Markup % (your profit margin)**
|
||||
@@ -718,15 +737,17 @@ public static class HelpKnowledgeBase
|
||||
1. **Surface area** — calculated from the dimensions you enter (length × width × quantity, adjusted for shape), or entered manually
|
||||
2. **Powder material cost** — if an inventory item is selected: (item cost per lb ÷ (coverage rate × efficiency)) × surface area. If no item selected, uses the fallback Powder Cost Per Sq Ft from Operating Costs
|
||||
3. **Additional coats** — each additional coat adds more material cost + Additional Coat Labor % of base labor
|
||||
4. **Shop supplies** — Shop Supplies Rate % × (material cost + labor cost)
|
||||
5. **Labor cost** — Standard Labor Rate × estimated hours. Prep service modifiers: Sandblasting = 1.5× rate, Masking = 0.5× rate
|
||||
6. **Equipment costs** — Oven, Sandblaster, Coating Booth each contribute their $/hr rate × estimated time
|
||||
7. **Complexity adjustment** — the complexity multiplier % is added to the item subtotal
|
||||
8. **General Markup** — applied as a multiplier on the running total (profit margin)
|
||||
9. **Rush charge** — added if the job/quote priority is Rush or Urgent
|
||||
10. **Customer tier discount** — if the customer has a pricing tier, their discount % is subtracted
|
||||
11. **Tax** — Tax % applied to the subtotal (skipped for tax-exempt customers)
|
||||
12. **Shop minimum** — if the total is below the Shop Minimum Charge, the minimum is used instead
|
||||
4. **Labor cost** — Standard Labor Rate × estimated hours. Prep service modifiers: Sandblasting = 1.5× rate, Masking = 0.5× rate
|
||||
5. **Equipment costs** — Oven, Sandblaster, Coating Booth each contribute their $/hr rate × estimated time
|
||||
6. **Complexity adjustment** — the complexity multiplier % is added to the item subtotal
|
||||
7. **Oven batch cost** — if a named oven is assigned to the quote, the batch cost is added based on the oven's hourly rate and cure cycle
|
||||
8. **Facility overhead** — (Monthly Rent + Monthly Utilities) ÷ Monthly Billable Hours × total estimated job hours. Shows as a separate "Facility Overhead" line in the breakdown. Skipped if rent and utilities are both $0.
|
||||
9. **Shop supplies** — Shop Supplies Rate % × (items subtotal + oven batch cost). Applied before facility overhead so supplies don't compound overhead.
|
||||
10. **General Markup** — applied as a multiplier on the running total (profit margin)
|
||||
11. **Customer tier discount** — if the customer has a pricing tier, their discount % is subtracted
|
||||
12. **Rush charge** — added if the job/quote priority is Rush or Urgent
|
||||
13. **Tax** — Tax % applied to the subtotal (skipped for tax-exempt customers)
|
||||
14. **Shop minimum** — if the total is below the Shop Minimum Charge, the minimum is used instead
|
||||
|
||||
**To get accurate quotes, you need:**
|
||||
- Operating Costs all filled in (Steps 3–4 of the wizard)
|
||||
@@ -776,7 +797,7 @@ public static class HelpKnowledgeBase
|
||||
- Footer note (quotes and invoices only)
|
||||
|
||||
**Operating Costs tab**
|
||||
All pricing rates. See the "Operating Costs Field Guide" section above for full details.
|
||||
All pricing rates including labor rates, equipment hourly costs, material costs, facility overhead (rent + utilities + billable hours), markup, and minimums. See the "Operating Costs Field Guide" section above for full details.
|
||||
|
||||
**Named Ovens tab**
|
||||
Configure physical ovens for the Oven Scheduler. Each oven has a name, cost per hour, max load capacity (sq ft), and default cure cycle (minutes). The first oven's cost also updates the Oven Operating Cost rate used in quote pricing.
|
||||
@@ -1099,6 +1120,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 +1142,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>();
|
||||
|
||||
@@ -361,6 +361,60 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Facility Overhead -->
|
||||
<h6 class="border-bottom pb-2 mb-3 mt-3">Facility Overhead
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Facility Overhead"
|
||||
data-bs-content="Enter your monthly shop rent and combined utility costs. The system divides these by your estimated billable hours to derive a per-hour overhead rate, which is then added to every quote proportionally to the estimated job time. This ensures fixed facility costs are recovered across all jobs rather than absorbed into your markup.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h6>
|
||||
<div class="row align-items-start">
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label for="monthlyRent" class="form-label">Monthly Rent</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" step="0.01" class="form-control facility-overhead-input" id="monthlyRent" name="MonthlyRent" value="@(Model.OperatingCosts?.MonthlyRent ?? 0)" min="0" max="1000000">
|
||||
<span class="input-group-text">/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label for="monthlyUtilities" class="form-label">Monthly Utilities</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" step="0.01" class="form-control facility-overhead-input" id="monthlyUtilities" name="MonthlyUtilities" value="@(Model.OperatingCosts?.MonthlyUtilities ?? 0)" min="0" max="1000000">
|
||||
<span class="input-group-text">/mo</span>
|
||||
</div>
|
||||
<small class="text-muted">Electricity, gas, water, internet</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label for="monthlyBillableHours" class="form-label">Billable Hours/Month</label>
|
||||
<div class="input-group">
|
||||
<input type="number" step="1" class="form-control facility-overhead-input" id="monthlyBillableHours" name="MonthlyBillableHours" value="@(Model.OperatingCosts?.MonthlyBillableHours ?? 160)" min="1" max="10000">
|
||||
<span class="input-group-text">hrs</span>
|
||||
</div>
|
||||
<small class="text-muted">Typical: 160 hrs (4 wks × 40 hrs)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted">Calculated Rate</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light"><i class="bi bi-calculator"></i></span>
|
||||
<input type="text" class="form-control bg-light" id="facilityOverheadRateDisplay" readonly
|
||||
value="@((Model.OperatingCosts?.FacilityOverheadRatePerHour ?? 0).ToString("C2")) / hr">
|
||||
</div>
|
||||
<small class="text-muted">Added to quotes per estimated labor hour</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equipment Operating Costs -->
|
||||
<h6 class="border-bottom pb-2 mb-3 mt-3">Equipment Operating Costs
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
@@ -706,10 +760,16 @@
|
||||
<small class="text-muted"><span id="aiProfileCharCount">@(Model.OperatingCosts?.AiContextProfile?.Length ?? 0)</span>/2000</small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="btnSaveAiProfile">
|
||||
<i class="bi bi-floppy me-1"></i> Save AI Profile
|
||||
</button>
|
||||
<span id="aiProfileStatus" class="ms-3 small"></span>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<button type="button" class="btn btn-primary" id="btnSaveAiProfile">
|
||||
<i class="bi bi-floppy me-1"></i> Save AI Profile
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="btnGenerateAiDraft"
|
||||
title="Build a suggested profile from your existing settings — ovens, workers, inventory categories, and rates">
|
||||
<i class="bi bi-stars me-1"></i> Generate from my settings
|
||||
</button>
|
||||
<span id="aiProfileStatus" class="small"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
@@ -2131,6 +2191,16 @@
|
||||
});
|
||||
|
||||
// Operating Costs Form Submit
|
||||
// Live facility overhead rate preview
|
||||
function updateFacilityOverheadRate() {
|
||||
var rent = parseFloat($('#monthlyRent').val()) || 0;
|
||||
var utilities = parseFloat($('#monthlyUtilities').val()) || 0;
|
||||
var hours = parseInt($('#monthlyBillableHours').val()) || 1;
|
||||
var rate = hours > 0 ? (rent + utilities) / hours : 0;
|
||||
$('#facilityOverheadRateDisplay').val('$' + rate.toFixed(2) + ' / hr');
|
||||
}
|
||||
$('.facility-overhead-input').on('input', updateFacilityOverheadRate);
|
||||
|
||||
$('#operatingCostsForm').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -2153,7 +2223,10 @@
|
||||
ComplexitySimplePercent: parseFloat($('#complexitySimplePercent').val()) || 0,
|
||||
ComplexityModeratePercent: parseFloat($('#complexityModeratePercent').val()) || 5,
|
||||
ComplexityComplexPercent: parseFloat($('#complexityComplexPercent').val()) || 15,
|
||||
ComplexityExtremePercent: parseFloat($('#complexityExtremePercent').val()) || 25
|
||||
ComplexityExtremePercent: parseFloat($('#complexityExtremePercent').val()) || 25,
|
||||
MonthlyRent: parseFloat($('#monthlyRent').val()) || 0,
|
||||
MonthlyUtilities: parseFloat($('#monthlyUtilities').val()) || 0,
|
||||
MonthlyBillableHours: parseInt($('#monthlyBillableHours').val()) || 160
|
||||
};
|
||||
|
||||
const btn = $('#btnSaveOperatingCosts');
|
||||
@@ -2210,6 +2283,32 @@
|
||||
});
|
||||
});
|
||||
|
||||
$('#btnGenerateAiDraft').on('click', function () {
|
||||
const btn = $(this);
|
||||
const existing = $('#aiContextProfile').val().trim();
|
||||
if (existing && !confirm('This will replace your current profile text with a generated draft. Continue?')) return;
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> Generating...');
|
||||
$.ajax({
|
||||
url: '@Url.Action("GenerateAiProfileDraft", "CompanySettings")',
|
||||
type: 'GET',
|
||||
success: function (response) {
|
||||
if (response.success) {
|
||||
$('#aiContextProfile').val(response.draft);
|
||||
$('#aiProfileCharCount').text(response.draft.length);
|
||||
showToast('info', 'Draft generated — review and edit it, then click Save AI Profile.');
|
||||
} else {
|
||||
showToast('error', response.message);
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
showToast('error', 'An error occurred while generating the draft.');
|
||||
},
|
||||
complete: function () {
|
||||
btn.prop('disabled', false).html('<i class="bi bi-stars me-1"></i> Generate from my settings');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Quoting Calibration — save
|
||||
$('#saveBlastProfile').on('click', function () {
|
||||
var btn = $(this);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -101,6 +101,33 @@
|
||||
<li class="mb-1"><strong>Shop Supplies Rate (%)</strong> — a percentage applied to material and labor costs to cover miscellaneous shop supplies (abrasives, tape, fasteners, etc.) that are not tracked per-job.</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2" id="facility-overhead">Facility Overhead (Rent & Utilities)</h3>
|
||||
<p>
|
||||
Facility overhead lets you recover your shop's fixed occupancy costs on every quote automatically.
|
||||
Rather than burying rent and utilities in your General Markup, entering them here makes the cost
|
||||
transparent in the pricing breakdown and ensures accurate job costing.
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-1"><strong>Monthly Rent ($)</strong> — your monthly shop lease or mortgage payment for the production facility.</li>
|
||||
<li class="mb-1"><strong>Monthly Utilities ($)</strong> — monthly gas, electricity, and water costs for the shop. Do not include costs already captured in your oven/equipment hourly rates.</li>
|
||||
<li class="mb-1"><strong>Monthly Billable Hours</strong> — the number of productive labor hours your shop operates per month (default: 160 — roughly one full-time worker for a month). This is used to convert the combined rent + utilities into a per-hour overhead rate.</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>How it is applied:</strong> The system calculates a per-hour overhead rate as
|
||||
<code>(Monthly Rent + Monthly Utilities) ÷ Monthly Billable Hours</code>. For each quote, it
|
||||
multiplies that rate by the total estimated labor hours across all line items and adds the result
|
||||
as a separate line in the pricing breakdown. If rent and utilities are both $0, no overhead charge
|
||||
is added.
|
||||
</p>
|
||||
<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>
|
||||
<strong>Example:</strong> $2,000/month rent + $800/month utilities ÷ 160 billable hours =
|
||||
<strong>$17.50/hr overhead rate</strong>. A quote with 4 total estimated hours would add
|
||||
$70 to the price as "Facility Overhead."
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Pricing Mode, Markup, Minimums & Rush Charges</h3>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-1">
|
||||
|
||||
@@ -56,23 +56,22 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Customer</label>
|
||||
<p class="mb-0">
|
||||
<a asp-controller="Customers" asp-action="Details" asp-route-id="@Model.CustomerId" class="text-decoration-none">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.CustomerCompanyName))
|
||||
@Html.AntiForgeryToken()
|
||||
<div data-cc-wrap data-cc-id="@Model.Id"
|
||||
data-cc-url="@Url.Action("ChangeCustomer", "Jobs")">
|
||||
<select class="form-select form-select-sm cc-select" style="max-width:300px;">
|
||||
@foreach (var c in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.CustomerSelectList)
|
||||
{
|
||||
<strong>@Model.CustomerCompanyName</strong>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.CustomerContactName))
|
||||
{
|
||||
<br />
|
||||
<small class="text-muted">@Model.CustomerContactName</small>
|
||||
}
|
||||
<option value="@c.Value" selected="@(c.Value == Model.CustomerId.ToString() ? "selected" : null)">@c.Text</option>
|
||||
}
|
||||
else
|
||||
{
|
||||
@Model.CustomerName
|
||||
}
|
||||
</a>
|
||||
</p>
|
||||
</select>
|
||||
<div class="cc-confirm-banner d-none mt-2 p-2 bg-light border rounded d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="cc-confirm-text small fw-semibold"></span>
|
||||
<button type="button" class="btn btn-success btn-sm" data-cc-save>Save</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-cc-cancel>Cancel</button>
|
||||
</div>
|
||||
<div class="cc-error text-danger small mt-1 d-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Priority</label>
|
||||
@@ -2140,6 +2139,7 @@
|
||||
@section Scripts {
|
||||
<link rel="stylesheet" href="~/css/job-photos.css" />
|
||||
<script src="~/js/job-photos.js" asp-append-version="true"></script>
|
||||
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
// ── Inline date editing ──────────────────────────────────────────────
|
||||
const jobId = @Model.Id;
|
||||
|
||||
@@ -80,12 +80,24 @@
|
||||
else
|
||||
{
|
||||
<div class="col-md-12">
|
||||
<p>
|
||||
<strong>Customer:</strong>
|
||||
<a asp-controller="Customers" asp-action="Details" asp-route-id="@Model.CustomerId">
|
||||
@Model.CustomerName
|
||||
</a>
|
||||
</p>
|
||||
@Html.AntiForgeryToken()
|
||||
<strong>Customer:</strong>
|
||||
<div data-cc-wrap data-cc-id="@Model.Id"
|
||||
data-cc-url="@Url.Action("ChangeCustomer", "Quotes")"
|
||||
class="d-inline-block ms-1">
|
||||
<select class="form-select form-select-sm cc-select" style="max-width:300px;">
|
||||
@foreach (var c in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.CustomerSelectList)
|
||||
{
|
||||
<option value="@c.Value" selected="@(c.Value == Model.CustomerId.ToString() ? "selected" : null)">@c.Text</option>
|
||||
}
|
||||
</select>
|
||||
<div class="cc-confirm-banner d-none mt-2 p-2 bg-light border rounded d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="cc-confirm-text small fw-semibold"></span>
|
||||
<button type="button" class="btn btn-success btn-sm" data-cc-save>Save</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-cc-cancel>Cancel</button>
|
||||
</div>
|
||||
<div class="cc-error text-danger small mt-1 d-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -891,6 +903,14 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.PricingBreakdown.FacilityOverheadCost > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span><i class="bi bi-building me-1"></i>Facility Overhead (@Model.PricingBreakdown.FacilityOverheadRatePerHour.ToString("C2")/hr):</span>
|
||||
<strong>@Model.PricingBreakdown.FacilityOverheadCost.ToString("C")</strong>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.PricingBreakdown.ShopSuppliesAmount > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
@@ -1031,7 +1051,7 @@
|
||||
</div>
|
||||
|
||||
@* ── SECTION 2: Quote-Level Additions ───────────────────── *@
|
||||
@if (pb.OvenBatchCost > 0 || pb.ShopSuppliesAmount > 0 || pb.OverheadCosts > 0)
|
||||
@if (pb.OvenBatchCost > 0 || pb.FacilityOverheadCost > 0 || pb.ShopSuppliesAmount > 0 || pb.OverheadCosts > 0)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<div class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.05em;">
|
||||
@@ -1044,6 +1064,13 @@
|
||||
<span>@pb.OvenBatchCost.ToString("C")</span>
|
||||
</div>
|
||||
}
|
||||
@if (pb.FacilityOverheadCost > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">Facility overhead (@pb.FacilityOverheadRatePerHour.ToString("C2")/hr × estimated hours)</span>
|
||||
<span>@pb.FacilityOverheadCost.ToString("C")</span>
|
||||
</div>
|
||||
}
|
||||
@if (pb.ShopSuppliesAmount > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
@@ -1100,7 +1127,7 @@
|
||||
<span>@pb.Total.ToString("C")</span>
|
||||
</div>
|
||||
@{
|
||||
var totalDirectCost = pb.MaterialCosts + pb.LaborCosts + pb.EquipmentCosts + pb.OvenBatchCost + pb.ShopSuppliesAmount;
|
||||
var totalDirectCost = pb.MaterialCosts + pb.LaborCosts + pb.EquipmentCosts + pb.OvenBatchCost + pb.FacilityOverheadCost + pb.ShopSuppliesAmount;
|
||||
var grossProfit = pb.Total - totalDirectCost;
|
||||
var effectiveMargin = pb.Total > 0 ? (grossProfit / pb.Total * 100m) : 0m;
|
||||
var pricingModeLabel = dbgCosts?.PricingMode == PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost ? "margin" : "markup";
|
||||
@@ -2030,6 +2057,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
|
||||
|
||||
@@ -59,6 +59,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Facility Overhead -->
|
||||
<div class="wizard-card">
|
||||
<h5 class="wizard-card-title mb-3">Facility Overhead</h5>
|
||||
<p class="text-secondary small mb-3">
|
||||
Enter your monthly shop rent and utilities so the system can recover those costs proportionally
|
||||
across every job. Leave at zero if you work from home or your facility costs are already factored
|
||||
into your markup.
|
||||
</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label asp-for="MonthlyRent" class="form-label fw-semibold"></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input asp-for="MonthlyRent" class="form-control wz-overhead" step="0.01" type="number" min="0" />
|
||||
<span class="input-group-text">/mo</span>
|
||||
</div>
|
||||
<div class="form-text">Your monthly lease or rent payment for the shop space.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="MonthlyUtilities" class="form-label fw-semibold"></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input asp-for="MonthlyUtilities" class="form-control wz-overhead" step="0.01" type="number" min="0" />
|
||||
<span class="input-group-text">/mo</span>
|
||||
</div>
|
||||
<div class="form-text">Combined electricity, gas, water, and internet.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="MonthlyBillableHours" class="form-label fw-semibold"></label>
|
||||
<div class="input-group">
|
||||
<input asp-for="MonthlyBillableHours" class="form-control wz-overhead" step="1" type="number" min="1" />
|
||||
<span class="input-group-text">hrs</span>
|
||||
</div>
|
||||
<div class="form-text">Hours per month the shop is actively producing work. Default: 160 (4 wks × 40 hrs).</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-muted">Calculated overhead rate</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light"><i class="bi bi-calculator"></i></span>
|
||||
<input type="text" id="wzOverheadRate" class="form-control bg-light" readonly value="$0.00 / hr">
|
||||
</div>
|
||||
<div class="form-text">This amount is added to quotes per estimated labor hour.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equipment Costs -->
|
||||
<div class="wizard-card">
|
||||
<h5 class="wizard-card-title mb-3">Equipment Costs (per hour) <button type="button" class="btn btn-link btn-sm p-0 text-primary fw-normal ms-1" data-bs-toggle="modal" data-bs-target="#equipCalcModal"><i class="bi bi-calculator me-1"></i>Help me calculate</button></h5>
|
||||
@@ -336,6 +382,19 @@
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
// ── Facility Overhead Rate Preview ────────────────────────
|
||||
function updateWzOverheadRate() {
|
||||
var rent = parseFloat(document.querySelector('[name="MonthlyRent"]').value) || 0;
|
||||
var utils = parseFloat(document.querySelector('[name="MonthlyUtilities"]').value) || 0;
|
||||
var hours = parseInt(document.querySelector('[name="MonthlyBillableHours"]').value) || 1;
|
||||
var rate = hours > 0 ? (rent + utils) / hours : 0;
|
||||
document.getElementById('wzOverheadRate').value = '$' + rate.toFixed(2) + ' / hr';
|
||||
}
|
||||
document.querySelectorAll('.wz-overhead').forEach(function(el) {
|
||||
el.addEventListener('input', updateWzOverheadRate);
|
||||
});
|
||||
updateWzOverheadRate();
|
||||
|
||||
// ── Labor Calculator ──────────────────────────────────────
|
||||
function laborCalc() {
|
||||
var employees = parseFloat(document.getElementById('lc_employees').value) || 0;
|
||||
|
||||
@@ -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(); });
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -9,6 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
@@ -26,6 +27,7 @@
|
||||
<ProjectReference Include="..\..\src\PowderCoating.Core\PowderCoating.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\PowderCoating.Application\PowderCoating.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\PowderCoating.Infrastructure\PowderCoating.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\src\PowderCoating.Web\PowderCoating.Web.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
public class PricingCalculationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CalculateCoatPriceAsync_CustomPowder_ChargesFullOrderQuantity()
|
||||
{
|
||||
var costs = CreateOperatingCosts();
|
||||
var unitOfWork = CreateUnitOfWorkMock(costs);
|
||||
var tenantContext = new Mock<ITenantContext>();
|
||||
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
|
||||
|
||||
var service = new PricingCalculationService(
|
||||
unitOfWork.Object,
|
||||
Mock.Of<ILogger<PricingCalculationService>>(),
|
||||
new MeasurementConversionService(),
|
||||
tenantContext.Object);
|
||||
|
||||
var coat = new CreateQuoteItemCoatDto
|
||||
{
|
||||
CoatName = "Custom Red",
|
||||
PowderCostPerLb = 10m,
|
||||
PowderToOrder = 3m,
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m
|
||||
};
|
||||
|
||||
var result = await service.CalculateCoatPriceAsync(
|
||||
coat,
|
||||
itemSurfaceAreaSqFt: 5m,
|
||||
quantity: 2m,
|
||||
coatIndex: 0,
|
||||
estimatedMinutesBase: 15,
|
||||
companyId: 1);
|
||||
|
||||
Assert.Equal(30m, result.CoatMaterialCost);
|
||||
Assert.Equal(30m, result.CoatLaborCost);
|
||||
Assert.Equal(60m, result.CoatTotalCost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateQuoteItemPriceAsync_LaborItem_UsesStandardLaborRate()
|
||||
{
|
||||
var costs = CreateOperatingCosts();
|
||||
costs.StandardLaborRate = 80m;
|
||||
|
||||
var unitOfWork = CreateUnitOfWorkMock(costs);
|
||||
var tenantContext = new Mock<ITenantContext>();
|
||||
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
|
||||
|
||||
var service = new PricingCalculationService(
|
||||
unitOfWork.Object,
|
||||
Mock.Of<ILogger<PricingCalculationService>>(),
|
||||
new MeasurementConversionService(),
|
||||
tenantContext.Object);
|
||||
|
||||
var item = new CreateQuoteItemDto
|
||||
{
|
||||
Description = "Shop labor",
|
||||
IsLaborItem = true,
|
||||
Quantity = 2.5m
|
||||
};
|
||||
|
||||
var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1);
|
||||
|
||||
Assert.Equal(0m, result.MaterialCost);
|
||||
Assert.Equal(200m, result.LaborCost);
|
||||
Assert.Equal(80m, result.UnitPrice);
|
||||
Assert.Equal(200m, result.TotalPrice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateQuoteItemPriceAsync_AiItem_UsesManualUnitPriceWithoutAdditionalCosts()
|
||||
{
|
||||
var unitOfWork = CreateUnitOfWorkMock(CreateOperatingCosts());
|
||||
var tenantContext = new Mock<ITenantContext>();
|
||||
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
|
||||
|
||||
var service = new PricingCalculationService(
|
||||
unitOfWork.Object,
|
||||
Mock.Of<ILogger<PricingCalculationService>>(),
|
||||
new MeasurementConversionService(),
|
||||
tenantContext.Object);
|
||||
|
||||
var item = new CreateQuoteItemDto
|
||||
{
|
||||
Description = "AI wheel estimate",
|
||||
IsAiItem = true,
|
||||
ManualUnitPrice = 123m,
|
||||
Quantity = 2m
|
||||
};
|
||||
|
||||
var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1);
|
||||
|
||||
Assert.Equal(0m, result.MaterialCost);
|
||||
Assert.Equal(0m, result.LaborCost);
|
||||
Assert.Equal(123m, result.UnitPrice);
|
||||
Assert.Equal(246m, result.TotalPrice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateQuoteTotalsAsync_AppliesTierDiscount_QuoteDiscount_RushFee_AndTax()
|
||||
{
|
||||
var costs = CreateOperatingCosts();
|
||||
costs.StandardLaborRate = 100m;
|
||||
costs.ShopSuppliesRate = 10m;
|
||||
costs.RushChargeType = "Percentage";
|
||||
costs.RushChargePercentage = 20m;
|
||||
costs.TaxPercent = 5m;
|
||||
costs.OvenOperatingCostPerHour = 0m;
|
||||
costs.MonthlyRent = 0m;
|
||||
costs.MonthlyUtilities = 0m;
|
||||
|
||||
var customerRepo = new Mock<IRepository<Customer>>();
|
||||
customerRepo
|
||||
.Setup(x => x.FindAsync(It.IsAny<System.Linq.Expressions.Expression<Func<Customer, bool>>>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<Customer, object>>[]>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new Customer { Id = 1, CompanyId = 1, PricingTierId = 10 }
|
||||
});
|
||||
|
||||
var pricingTierRepo = new Mock<IRepository<PricingTier>>();
|
||||
pricingTierRepo
|
||||
.Setup(x => x.GetByIdAsync(10, false, It.IsAny<System.Linq.Expressions.Expression<Func<PricingTier, object>>[]>()))
|
||||
.ReturnsAsync(new PricingTier { Id = 10, CompanyId = 1, DiscountPercent = 10m });
|
||||
|
||||
var unitOfWork = CreateUnitOfWorkMock(costs);
|
||||
unitOfWork.SetupGet(x => x.Customers).Returns(customerRepo.Object);
|
||||
unitOfWork.SetupGet(x => x.PricingTiers).Returns(pricingTierRepo.Object);
|
||||
|
||||
var tenantContext = new Mock<ITenantContext>();
|
||||
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
|
||||
|
||||
var service = new PricingCalculationService(
|
||||
unitOfWork.Object,
|
||||
Mock.Of<ILogger<PricingCalculationService>>(),
|
||||
new MeasurementConversionService(),
|
||||
tenantContext.Object);
|
||||
|
||||
var items = new List<CreateQuoteItemDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Description = "Labor item",
|
||||
IsLaborItem = true,
|
||||
Quantity = 2m
|
||||
}
|
||||
};
|
||||
|
||||
var result = await service.CalculateQuoteTotalsAsync(
|
||||
items,
|
||||
companyId: 1,
|
||||
customerId: 1,
|
||||
discountType: "FixedAmount",
|
||||
discountValue: 5m,
|
||||
isRushJob: true);
|
||||
|
||||
Assert.Equal(200m, result.ItemsSubtotal);
|
||||
Assert.Equal(20m, result.ShopSuppliesAmount);
|
||||
Assert.Equal(220m, result.SubtotalBeforeDiscount);
|
||||
Assert.Equal(22m, result.PricingTierDiscountAmount);
|
||||
Assert.Equal(5m, result.QuoteDiscountAmount);
|
||||
Assert.Equal(193m, result.SubtotalAfterDiscount);
|
||||
Assert.Equal(38.6m, result.RushFee);
|
||||
Assert.Equal(11.58m, result.TaxAmount);
|
||||
Assert.Equal(243.18m, result.Total);
|
||||
}
|
||||
|
||||
private static Mock<IUnitOfWork> CreateUnitOfWorkMock(CompanyOperatingCosts costs)
|
||||
{
|
||||
var unitOfWork = new Mock<IUnitOfWork>();
|
||||
|
||||
var companyOperatingCostsRepo = new Mock<IRepository<CompanyOperatingCosts>>();
|
||||
companyOperatingCostsRepo
|
||||
.Setup(x => x.FindAsync(It.IsAny<System.Linq.Expressions.Expression<Func<CompanyOperatingCosts, bool>>>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<CompanyOperatingCosts, object>>[]>()))
|
||||
.ReturnsAsync(new[] { costs });
|
||||
|
||||
var inventoryRepo = new Mock<IRepository<InventoryItem>>();
|
||||
inventoryRepo
|
||||
.Setup(x => x.GetByIdAsync(It.IsAny<int>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<InventoryItem, object>>[]>()))
|
||||
.ReturnsAsync((InventoryItem?)null);
|
||||
|
||||
var catalogRepo = new Mock<IRepository<CatalogItem>>();
|
||||
catalogRepo
|
||||
.Setup(x => x.GetByIdAsync(It.IsAny<int>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<CatalogItem, object>>[]>()))
|
||||
.ReturnsAsync((CatalogItem?)null);
|
||||
|
||||
var customerRepo = new Mock<IRepository<Customer>>();
|
||||
customerRepo
|
||||
.Setup(x => x.FindAsync(It.IsAny<System.Linq.Expressions.Expression<Func<Customer, bool>>>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<Customer, object>>[]>()))
|
||||
.ReturnsAsync(Array.Empty<Customer>());
|
||||
|
||||
var pricingTierRepo = new Mock<IRepository<PricingTier>>();
|
||||
pricingTierRepo
|
||||
.Setup(x => x.GetByIdAsync(It.IsAny<int>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<PricingTier, object>>[]>()))
|
||||
.ReturnsAsync((PricingTier?)null);
|
||||
|
||||
unitOfWork.SetupGet(x => x.CompanyOperatingCosts).Returns(companyOperatingCostsRepo.Object);
|
||||
unitOfWork.SetupGet(x => x.InventoryItems).Returns(inventoryRepo.Object);
|
||||
unitOfWork.SetupGet(x => x.CatalogItems).Returns(catalogRepo.Object);
|
||||
unitOfWork.SetupGet(x => x.Customers).Returns(customerRepo.Object);
|
||||
unitOfWork.SetupGet(x => x.PricingTiers).Returns(pricingTierRepo.Object);
|
||||
|
||||
return unitOfWork;
|
||||
}
|
||||
|
||||
private static CompanyOperatingCosts CreateOperatingCosts()
|
||||
{
|
||||
return new CompanyOperatingCosts
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 1,
|
||||
StandardLaborRate = 60m,
|
||||
AdditionalCoatLaborPercent = 50m,
|
||||
OvenOperatingCostPerHour = 25m,
|
||||
SandblasterCostPerHour = 20m,
|
||||
CoatingBoothCostPerHour = 10m,
|
||||
PowderCoatingCostPerSqFt = 1m,
|
||||
PricingMode = PowderCoating.Core.Enums.PricingMode.MarkupOnMaterial,
|
||||
GeneralMarkupPercentage = 20m,
|
||||
TargetMarginPercent = 40m,
|
||||
TaxPercent = 5m,
|
||||
ShopSuppliesRate = 10m,
|
||||
DefaultOvenCycleMinutes = 60,
|
||||
RushChargeType = "Percentage",
|
||||
RushChargePercentage = 15m,
|
||||
RushChargeFixedAmount = 50m,
|
||||
ShopMinimumCharge = 0m,
|
||||
ComplexitySimplePercent = 0m,
|
||||
ComplexityModeratePercent = 5m,
|
||||
ComplexityComplexPercent = 15m,
|
||||
ComplexityExtremePercent = 25m,
|
||||
MonthlyBillableHours = 160
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Infrastructure.Repositories;
|
||||
using PowderCoating.Web.Controllers;
|
||||
using Xunit;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
public class RegistrationControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PaymentSuccess_WhenStripeSessionIsNotPaid_DoesNotBurnPendingSession()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.PendingRegistrationSessions.Add(CreatePendingSession("token-1", "owner@example.com"));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var stripeService = new Mock<IStripeService>();
|
||||
stripeService.Setup(x => x.IsRegistrationCheckoutPaidAsync("sess_unpaid")).ReturnsAsync(false);
|
||||
|
||||
var controller = CreateController(context, stripeService: stripeService);
|
||||
|
||||
var result = await controller.PaymentSuccess("sess_unpaid", "token-1");
|
||||
|
||||
var redirect = Assert.IsType<Microsoft.AspNetCore.Mvc.RedirectToActionResult>(result);
|
||||
Assert.Equal("Index", redirect.ActionName);
|
||||
Assert.False((await context.PendingRegistrationSessions.SingleAsync()).IsCompleted);
|
||||
Assert.Contains("couldn't verify a completed payment", controller.TempData["Error"]?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PaymentSuccess_WhenUserCreationFails_ReleasesPendingSessionAndDeletesCompany()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.PendingRegistrationSessions.Add(CreatePendingSession("token-2", "owner2@example.com"));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var userManager = CreateUserManagerMock();
|
||||
userManager.Setup(x => x.FindByEmailAsync("owner2@example.com")).ReturnsAsync((ApplicationUser?)null);
|
||||
userManager.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "boom" }));
|
||||
|
||||
var stripeService = new Mock<IStripeService>();
|
||||
stripeService.Setup(x => x.IsRegistrationCheckoutPaidAsync("sess_paid")).ReturnsAsync(true);
|
||||
|
||||
var controller = CreateController(context, userManager, stripeService: stripeService);
|
||||
|
||||
var result = await controller.PaymentSuccess("sess_paid", "token-2");
|
||||
|
||||
var redirect = Assert.IsType<Microsoft.AspNetCore.Mvc.RedirectToActionResult>(result);
|
||||
Assert.Equal("Index", redirect.ActionName);
|
||||
Assert.False((await context.PendingRegistrationSessions.SingleAsync()).IsCompleted);
|
||||
Assert.Empty(context.Companies);
|
||||
Assert.Contains("Please try the success link again", controller.TempData["Error"]?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PaymentSuccess_WhenSessionAlreadyCompletedAndUserExists_SignsUserInAndRedirectsToWelcome()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.PendingRegistrationSessions.Add(CreatePendingSession("token-3", "owner3@example.com", isCompleted: true));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var existingUser = new ApplicationUser
|
||||
{
|
||||
Id = "user-3",
|
||||
Email = "owner3@example.com",
|
||||
UserName = "owner3@example.com",
|
||||
FirstName = "Terry",
|
||||
LastName = "Tenant",
|
||||
CompanyId = 3
|
||||
};
|
||||
|
||||
var userManager = CreateUserManagerMock();
|
||||
userManager.Setup(x => x.FindByEmailAsync("owner3@example.com")).ReturnsAsync(existingUser);
|
||||
userManager.Setup(x => x.UpdateAsync(existingUser)).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
var signInManager = CreateSignInManagerMock(userManager.Object);
|
||||
signInManager.Setup(x => x.SignInAsync(existingUser, false, null)).Returns(Task.CompletedTask).Verifiable();
|
||||
|
||||
var controller = CreateController(context, userManager, signInManager.Object);
|
||||
|
||||
var result = await controller.PaymentSuccess("sess_complete", "token-3");
|
||||
|
||||
var redirect = Assert.IsType<Microsoft.AspNetCore.Mvc.RedirectToActionResult>(result);
|
||||
Assert.Equal("Welcome", redirect.ActionName);
|
||||
signInManager.Verify(x => x.SignInAsync(existingUser, false, null), Times.Once);
|
||||
Assert.True((await context.PendingRegistrationSessions.SingleAsync()).IsCompleted);
|
||||
}
|
||||
|
||||
private static RegistrationController CreateController(
|
||||
ApplicationDbContext context,
|
||||
Mock<UserManager<ApplicationUser>>? userManager = null,
|
||||
SignInManager<ApplicationUser>? signInManager = null,
|
||||
Mock<IStripeService>? stripeService = null)
|
||||
{
|
||||
var unitOfWork = new UnitOfWork(context);
|
||||
var userManagerMock = userManager ?? CreateUserManagerMock();
|
||||
var signInManagerInstance = signInManager ?? CreateSignInManagerMock(userManagerMock.Object).Object;
|
||||
|
||||
var platformSettings = new Mock<IPlatformSettingsService>();
|
||||
platformSettings.Setup(x => x.GetAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
|
||||
|
||||
var controller = new RegistrationController(
|
||||
unitOfWork,
|
||||
context,
|
||||
userManagerMock.Object,
|
||||
signInManagerInstance,
|
||||
Mock.Of<ISeedDataService>(),
|
||||
Mock.Of<IAdminNotificationService>(),
|
||||
Mock.Of<IInAppNotificationService>(),
|
||||
platformSettings.Object,
|
||||
(stripeService ?? new Mock<IStripeService>()).Object,
|
||||
Mock.Of<IEmailService>(),
|
||||
Mock.Of<ILogger<RegistrationController>>());
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
controller.ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext
|
||||
{
|
||||
HttpContext = httpContext
|
||||
};
|
||||
controller.TempData = new TempDataDictionary(httpContext, Mock.Of<ITempDataProvider>());
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> CreateUserManagerMock()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!);
|
||||
}
|
||||
|
||||
private static Mock<SignInManager<ApplicationUser>> CreateSignInManagerMock(UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
var contextAccessor = new Mock<IHttpContextAccessor>();
|
||||
contextAccessor.Setup(x => x.HttpContext).Returns(new DefaultHttpContext());
|
||||
|
||||
var claimsFactory = new Mock<IUserClaimsPrincipalFactory<ApplicationUser>>();
|
||||
|
||||
return new Mock<SignInManager<ApplicationUser>>(
|
||||
userManager,
|
||||
contextAccessor.Object,
|
||||
claimsFactory.Object,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!);
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static PendingRegistrationSession CreatePendingSession(string token, string email, bool isCompleted = false)
|
||||
{
|
||||
return new PendingRegistrationSession
|
||||
{
|
||||
Token = token,
|
||||
CompanyName = "Retry Co",
|
||||
CompanyPhone = "555-0100",
|
||||
FirstName = "Pat",
|
||||
LastName = "Owner",
|
||||
Email = email,
|
||||
Plan = 1,
|
||||
IsAnnual = false,
|
||||
IsCompleted = isCompleted,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Infrastructure.Repositories;
|
||||
using PowderCoating.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
public class SubscriptionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetUserCountAsync_PrefersCompanyOverrideOverPlanDefault()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompanyAndPlan(context, companyId: 7, plan: 1, maxUsers: 3);
|
||||
var company = context.Companies.Local.Single(c => c.Id == 7);
|
||||
company.MaxUsersOverride = 7;
|
||||
|
||||
context.Users.AddRange(
|
||||
new ApplicationUser { Id = "u1", CompanyId = 7, UserName = "u1", Email = "u1@example.com", FirstName = "A", LastName = "One", IsActive = true },
|
||||
new ApplicationUser { Id = "u2", CompanyId = 7, UserName = "u2", Email = "u2@example.com", FirstName = "B", LastName = "Two", IsActive = true });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
||||
|
||||
var (used, max) = await service.GetUserCountAsync(7);
|
||||
|
||||
Assert.Equal(2, used);
|
||||
Assert.Equal(7, max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJobCountAsync_ExcludesTerminalStatuses()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompanyAndPlan(context, companyId: 8, plan: 2, maxActiveJobs: 50);
|
||||
SeedJobStatuses(context, 8);
|
||||
context.Jobs.AddRange(
|
||||
new Job { Id = 1, CompanyId = 8, JobNumber = "JOB-1", CustomerId = 1, Description = "Active", JobStatusId = 1, JobPriorityId = 1 },
|
||||
new Job { Id = 2, CompanyId = 8, JobNumber = "JOB-2", CustomerId = 1, Description = "Done", JobStatusId = 2, JobPriorityId = 1 },
|
||||
new Job { Id = 3, CompanyId = 8, JobNumber = "JOB-3", CustomerId = 1, Description = "Delivered", JobStatusId = 3, JobPriorityId = 1 });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
||||
|
||||
var (used, max) = await service.GetJobCountAsync(8);
|
||||
|
||||
Assert.Equal(1, used);
|
||||
Assert.Equal(50, max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetQuoteCountAsync_CountsOnlyCurrentMonth()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompanyAndPlan(context, companyId: 9, plan: 3, maxQuotes: 5);
|
||||
var currentQuote = new Quote
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 9,
|
||||
QuoteNumber = "Q-001",
|
||||
QuoteStatusId = 1
|
||||
};
|
||||
var oldQuote = new Quote
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 9,
|
||||
QuoteNumber = "Q-OLD",
|
||||
QuoteStatusId = 1
|
||||
};
|
||||
context.Quotes.AddRange(currentQuote, oldQuote);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
oldQuote.CreatedAt = DateTime.UtcNow.AddMonths(-1);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
||||
|
||||
var (used, max) = await service.GetQuoteCountAsync(9);
|
||||
|
||||
Assert.Equal(1, used);
|
||||
Assert.Equal(5, max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanAddCustomerAsync_CompedCompany_BypassesPlanLimits()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompanyAndPlan(context, companyId: 10, plan: 4, maxCustomers: 0);
|
||||
var company = await context.Companies.FindAsync(10);
|
||||
company!.IsComped = true;
|
||||
context.Customers.Add(new Customer { Id = 1, CompanyId = 10, CompanyName = "Customer A" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
||||
|
||||
var allowed = await service.CanAddCustomerAsync(10);
|
||||
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanUseAiPhotoQuoteAsync_RequiresFeatureEnabledAndQuotaAvailable()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompanyAndPlan(context, companyId: 11, plan: 5, maxAiPhotoQuotesPerMonth: 2, allowAiPhotoQuotes: true);
|
||||
context.AiItemPredictions.Add(new AiItemPrediction { Id = 1, CompanyId = 11, CreatedAt = DateTime.UtcNow.AddDays(-1) });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
||||
|
||||
var allowed = await service.CanUseAiPhotoQuoteAsync(11);
|
||||
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanUseAiPhotoQuoteAsync_ReturnsFalse_WhenPlanDisablesFeature()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompanyAndPlan(context, companyId: 12, plan: 6, maxAiPhotoQuotesPerMonth: 10, allowAiPhotoQuotes: false);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
||||
|
||||
var allowed = await service.CanUseAiPhotoQuoteAsync(12);
|
||||
|
||||
Assert.False(allowed);
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static void SeedCompanyAndPlan(
|
||||
ApplicationDbContext context,
|
||||
int companyId,
|
||||
int plan,
|
||||
int maxUsers = -1,
|
||||
int maxActiveJobs = -1,
|
||||
int maxCustomers = -1,
|
||||
int maxQuotes = -1,
|
||||
int maxAiPhotoQuotesPerMonth = -1,
|
||||
bool allowAiPhotoQuotes = true)
|
||||
{
|
||||
context.Companies.Add(new Company
|
||||
{
|
||||
Id = companyId,
|
||||
CompanyId = companyId,
|
||||
CompanyName = $"Company {companyId}",
|
||||
PrimaryContactName = "Owner",
|
||||
PrimaryContactEmail = $"owner{companyId}@example.com",
|
||||
SubscriptionPlan = plan,
|
||||
SubscriptionStatus = SubscriptionStatus.Active,
|
||||
IsActive = true
|
||||
});
|
||||
|
||||
context.SubscriptionPlanConfigs.Add(new SubscriptionPlanConfig
|
||||
{
|
||||
Id = companyId,
|
||||
CompanyId = 0,
|
||||
Plan = plan,
|
||||
DisplayName = $"Plan {plan}",
|
||||
IsActive = true,
|
||||
MaxUsers = maxUsers,
|
||||
MaxActiveJobs = maxActiveJobs,
|
||||
MaxCustomers = maxCustomers,
|
||||
MaxQuotes = maxQuotes,
|
||||
MaxAiPhotoQuotesPerMonth = maxAiPhotoQuotesPerMonth,
|
||||
AllowAiPhotoQuotes = allowAiPhotoQuotes
|
||||
});
|
||||
|
||||
context.JobPriorityLookups.Add(new JobPriorityLookup
|
||||
{
|
||||
Id = companyId,
|
||||
CompanyId = companyId,
|
||||
PriorityCode = "NORMAL",
|
||||
DisplayName = "Normal",
|
||||
DisplayOrder = 1
|
||||
});
|
||||
}
|
||||
|
||||
private static void SeedJobStatuses(ApplicationDbContext context, int companyId)
|
||||
{
|
||||
context.JobStatusLookups.AddRange(
|
||||
new JobStatusLookup
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = companyId,
|
||||
StatusCode = "Pending",
|
||||
DisplayName = "Pending",
|
||||
DisplayOrder = 1,
|
||||
IsTerminalStatus = false
|
||||
},
|
||||
new JobStatusLookup
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = companyId,
|
||||
StatusCode = "Completed",
|
||||
DisplayName = "Completed",
|
||||
DisplayOrder = 2,
|
||||
IsTerminalStatus = true
|
||||
},
|
||||
new JobStatusLookup
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = companyId,
|
||||
StatusCode = "Delivered",
|
||||
DisplayName = "Delivered",
|
||||
DisplayOrder = 3,
|
||||
IsTerminalStatus = true
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user