Ad-hoc quote email, accounting improvements, AI lookup fix, and misc service updates
- Quotes: ad-hoc email modal on Quote Details lets staff send to an address not on file; QuotesController passes overrideEmail through to NotificationService - Quotes/Details view: SMS consent display, email/SMS send button state based on consent - Accounting module: AccountingDisplayHelpers for consistent ledger formatting; AccountsController + Accounts views improvements; AccountingEnums additions - Bills/Expenses: AI account categorization fixes in BillsController and ExpensesController - InventoryAiLookupService: TDS cure fallback no longer fires on AiAugmentFromUrl path (LookupByUrlAsync already has it built in — was double-fetching) - PdfService: quote/invoice PDF updates - PricingCalculationService: minor pricing logic fix - QuoteProfile: mapping updates for new quote fields - ApplicationDbContextModelSnapshot: catches up to all 4 migrations in this branch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -78,6 +78,7 @@ public class QuoteProfile : Profile
|
||||
// CreateQuoteDto -> Quote
|
||||
CreateMap<CreateQuoteDto, Quote>()
|
||||
.ForMember(dest => dest.Id, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.ProspectSmsConsentedAt, opt => opt.Ignore()) // Set by controller on consent
|
||||
.ForMember(dest => dest.QuoteNumber, opt => opt.Ignore()) // Generated by controller
|
||||
.ForMember(dest => dest.QuoteStatus, opt => opt.Ignore()) // Will be set by FK to Draft status
|
||||
.ForMember(dest => dest.OvenCost, opt => opt.Ignore())
|
||||
@@ -111,6 +112,7 @@ public class QuoteProfile : Profile
|
||||
// UpdateQuoteDto -> Quote
|
||||
CreateMap<UpdateQuoteDto, Quote>()
|
||||
.ForMember(dest => dest.QuoteNumber, opt => opt.Ignore()) // Cannot change
|
||||
.ForMember(dest => dest.ProspectSmsConsentedAt, opt => opt.Ignore()) // Managed by controller
|
||||
.ForMember(dest => dest.CustomerId, opt => opt.Ignore()) // Cannot change after creation - preserved in controller
|
||||
.ForMember(dest => dest.QuoteStatus, opt => opt.Ignore()) // Will be set by FK
|
||||
.ForMember(dest => dest.OvenCost, opt => opt.Ignore())
|
||||
@@ -277,6 +279,8 @@ public class QuoteProfile : Profile
|
||||
.ForMember(dest => dest.ZipCode, opt => opt.MapFrom(src => src.ProspectZipCode))
|
||||
.ForMember(dest => dest.IsCommercial, opt => opt.MapFrom(src => src.IsCommercial))
|
||||
.ForMember(dest => dest.CreditLimit, opt => opt.MapFrom(src => 0m))
|
||||
.ForMember(dest => dest.SmsConsent, opt => opt.MapFrom(src => src.ProspectSmsConsent))
|
||||
.ForMember(dest => dest.ProspectSmsConsentedAt, opt => opt.MapFrom(src => src.ProspectSmsConsentedAt))
|
||||
.ForMember(dest => dest.PricingTierId, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.TaxId, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.PaymentTerms, opt => opt.Ignore())
|
||||
|
||||
@@ -98,7 +98,12 @@ public class PdfService : IPdfService
|
||||
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeInvoiceHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
|
||||
page.Content().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
|
||||
page.Content().Layers(layers =>
|
||||
{
|
||||
layers.PrimaryLayer().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
|
||||
if (invoiceDto.Status == InvoiceStatus.Paid)
|
||||
layers.Layer().Element(c => ComposePaidStamp(c));
|
||||
});
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber();
|
||||
@@ -153,7 +158,6 @@ public class PdfService : IPdfService
|
||||
if (invoice.DueDate.HasValue)
|
||||
column.Item().Text($"Due: {invoice.DueDate.Value:MMMM d, yyyy}").FontSize(9).FontColor(
|
||||
invoice.Status == Core.Enums.InvoiceStatus.Overdue ? Colors.Red.Medium : Colors.Grey.Darken2);
|
||||
column.Item().Text($"Status: {invoice.Status}").FontSize(9);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,6 +165,27 @@ public class PdfService : IPdfService
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a semi-transparent angled PAID stamp centred over the invoice content layer.
|
||||
/// Uses QuestPDF layout primitives (AlignCenter, AlignMiddle, Rotate, Opacity) so no
|
||||
/// external Skia/SkiaSharp dependency is needed.
|
||||
/// </summary>
|
||||
private static void ComposePaidStamp(IContainer container)
|
||||
{
|
||||
container
|
||||
.AlignCenter()
|
||||
.AlignMiddle()
|
||||
.Rotate(-45f)
|
||||
.Border(5)
|
||||
.BorderColor(Colors.Green.Darken2)
|
||||
.PaddingVertical(14)
|
||||
.PaddingHorizontal(28)
|
||||
.Text("PAID")
|
||||
.FontSize(80)
|
||||
.Bold()
|
||||
.FontColor(Colors.Green.Darken2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the body of the invoice PDF: bill-to address block, job reference, alternating-row
|
||||
/// line-item table, and a right-aligned totals block that conditionally shows discount, tax,
|
||||
|
||||
@@ -126,9 +126,11 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
|
||||
// A coat is "custom" (must be purchased) when it has no inventory item but has a manual price.
|
||||
// In-stock coats reference an inventory item that already has stock on hand.
|
||||
// Incoming coats reference an inventory item with IsIncoming=true (ordered, not yet received).
|
||||
bool isCustomPowder = !coat.InventoryItemId.HasValue
|
||||
&& coat.PowderCostPerLb.HasValue
|
||||
&& coat.PowderCostPerLb.Value > 0;
|
||||
bool isIncomingPowder = false;
|
||||
|
||||
if (coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
||||
{
|
||||
@@ -143,13 +145,14 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
}
|
||||
else if (coat.InventoryItemId.HasValue && coat.InventoryItemId.Value > 0)
|
||||
{
|
||||
// In-stock powder - use inventory cost
|
||||
// In-stock or incoming powder - use inventory cost
|
||||
try
|
||||
{
|
||||
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
|
||||
if (inventoryItem != null && inventoryItem.UnitCost > 0)
|
||||
{
|
||||
costPerLb = inventoryItem.UnitCost;
|
||||
isIncomingPowder = inventoryItem.IsIncoming;
|
||||
var coverage = coat.CoverageSqFtPerLb;
|
||||
var transferEfficiency = coat.TransferEfficiency;
|
||||
|
||||
@@ -157,8 +160,8 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m);
|
||||
powderCostPerSqFt = actualPoundsPerSqFt * costPerLb;
|
||||
|
||||
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem}, UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
|
||||
coat.CoatName, inventoryItem.Name, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt);
|
||||
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem} (IsIncoming={IsIncoming}), UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
|
||||
coat.CoatName, inventoryItem.Name, isIncomingPowder, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -172,13 +175,13 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
var batchSurfaceAreaSqFt = perItemSurfaceAreaSqFt * quantity;
|
||||
decimal coatMaterialCost;
|
||||
|
||||
if (batchSurfaceAreaSqFt > 0 && isCustomPowder && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||
// Custom or incoming powder must be purchased for this job — charge for the full ordered
|
||||
// quantity so the shop recovers the actual outlay, not just the calculated usage.
|
||||
if (batchSurfaceAreaSqFt > 0 && (isCustomPowder || isIncomingPowder) && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||
{
|
||||
// Custom powder that must be purchased: charge for the full ordered quantity, not just
|
||||
// the calculated usage. The shop is spending money on the entire order for this job.
|
||||
coatMaterialCost = coat.PowderToOrder.Value * costPerLb;
|
||||
_logger.LogInformation("Coat {CoatName}: Custom powder to order — charging full order qty {Lbs}lb × ${CostPerLb}/lb = ${Total} (calculated usage would have been ${Calc})",
|
||||
coat.CoatName, coat.PowderToOrder.Value, costPerLb, coatMaterialCost, batchSurfaceAreaSqFt * powderCostPerSqFt);
|
||||
_logger.LogInformation("Coat {CoatName}: {PowderKind} powder to order — charging full order qty {Lbs}lb × ${CostPerLb}/lb = ${Total} (calculated usage would have been ${Calc})",
|
||||
coat.CoatName, isIncomingPowder ? "Incoming" : "Custom", coat.PowderToOrder.Value, costPerLb, coatMaterialCost, batchSurfaceAreaSqFt * powderCostPerSqFt);
|
||||
}
|
||||
else if (batchSurfaceAreaSqFt > 0)
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ public enum AccountType
|
||||
public enum AccountSubType
|
||||
{
|
||||
// Assets
|
||||
Cash = 8,
|
||||
Checking = 1,
|
||||
Savings = 2,
|
||||
AccountsReceivable = 3,
|
||||
|
||||
@@ -2431,6 +2431,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("Address")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("BillingEmail")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("City")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -3279,6 +3282,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsIncoming")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastPurchaseDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -3844,6 +3850,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasDefaultValueSql("NEWID()");
|
||||
|
||||
b.Property<decimal>("ShopSuppliesAmount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("ShopSuppliesPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int?>("ShopWorkerId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -6059,7 +6071,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(846),
|
||||
CreatedAt = new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4358),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6070,7 +6082,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(852),
|
||||
CreatedAt = new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4424),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6081,7 +6093,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(853),
|
||||
CreatedAt = new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4426),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -6397,6 +6409,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("ProspectPhone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("ProspectSmsConsent")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("ProspectSmsConsentedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("ProspectState")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
||||
@@ -407,7 +407,7 @@ Rules:
|
||||
/// known product page URL without running a Serper search. Used after a catalog hit
|
||||
/// to augment the catalog record with fields the catalog table doesn't store.
|
||||
/// </summary>
|
||||
public async Task<InventoryAiLookupResult> LookupByUrlAsync(string url, string? colorName)
|
||||
public async Task<InventoryAiLookupResult> LookupByUrlAsync(string url, string? colorName, string? tdsFallbackUrl = null)
|
||||
{
|
||||
var apiKey = _config["AI:Anthropic:ApiKey"];
|
||||
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
|
||||
@@ -484,6 +484,28 @@ Rules:
|
||||
};
|
||||
|
||||
ApplyPowderFallbacks(result);
|
||||
|
||||
// TDS fallback: use the TDS URL discovered from the product page, or the one the
|
||||
// caller passed in (e.g. known from catalog). Try it when cure specs are still missing.
|
||||
var effectiveTdsUrl = result.TdsUrl ?? tdsFallbackUrl;
|
||||
if (!string.IsNullOrWhiteSpace(effectiveTdsUrl) &&
|
||||
(result.CureTemperatureF == null || result.CureTimeMinutes == null))
|
||||
{
|
||||
try
|
||||
{
|
||||
var tds = await FetchTdsCureSpecsAsync(effectiveTdsUrl!, colorName);
|
||||
if (tds.Success)
|
||||
{
|
||||
if (result.CureTemperatureF == null) result.CureTemperatureF = tds.CureTemperatureF;
|
||||
if (result.CureTimeMinutes == null) result.CureTimeMinutes = tds.CureTimeMinutes;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "TDS fallback failed for {Url}", tdsFallbackUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -9,6 +9,7 @@ using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Helpers;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -88,8 +89,8 @@ public class AccountsController : Controller
|
||||
dto.AccountSubType = preSubType.Value;
|
||||
dto.AccountType = preSubType.Value switch
|
||||
{
|
||||
AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable
|
||||
or AccountSubType.Inventory or AccountSubType.FixedAsset
|
||||
AccountSubType.Cash or AccountSubType.Checking or AccountSubType.Savings
|
||||
or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.FixedAsset
|
||||
or AccountSubType.OtherCurrentAsset or AccountSubType.OtherAsset => AccountType.Asset,
|
||||
AccountSubType.AccountsPayable or AccountSubType.CreditCard
|
||||
or AccountSubType.OtherCurrentLiability or AccountSubType.LongTermLiability => AccountType.Liability,
|
||||
@@ -445,11 +446,11 @@ public class AccountsController : Controller
|
||||
.ToList();
|
||||
|
||||
ViewBag.AccountTypes = Enum.GetValues<AccountType>()
|
||||
.Select(t => new SelectListItem(t.ToString(), ((int)t).ToString()))
|
||||
.Select(t => new SelectListItem(t.ToDisplayName(), ((int)t).ToString()))
|
||||
.ToList();
|
||||
|
||||
ViewBag.AccountSubTypes = Enum.GetValues<AccountSubType>()
|
||||
.Select(t => new SelectListItem(t.ToString(), ((int)t).ToString()))
|
||||
.Select(t => new SelectListItem(t.ToDisplayName(), ((int)t).ToString()))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,7 +424,8 @@ public class BillsController : Controller
|
||||
|
||||
// Payment form defaults
|
||||
var bankAccounts = (await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.AccountSubType == AccountSubType.Checking ||
|
||||
a => a.AccountSubType == AccountSubType.Cash ||
|
||||
a.AccountSubType == AccountSubType.Checking ||
|
||||
a.AccountSubType == AccountSubType.Savings ||
|
||||
a.AccountSubType == AccountSubType.CreditCard))
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
@@ -949,7 +950,8 @@ public class BillsController : Controller
|
||||
.ToList();
|
||||
|
||||
ViewBag.BankAccounts = allAccounts
|
||||
.Where(a => a.AccountSubType == AccountSubType.Checking ||
|
||||
.Where(a => a.AccountSubType == AccountSubType.Cash ||
|
||||
a.AccountSubType == AccountSubType.Checking ||
|
||||
a.AccountSubType == AccountSubType.Savings ||
|
||||
a.AccountSubType == AccountSubType.CreditCard)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
|
||||
@@ -401,7 +401,8 @@ public class ExpensesController : Controller
|
||||
.ToList();
|
||||
|
||||
ViewBag.PaymentAccounts = allAccounts
|
||||
.Where(a => a.AccountSubType == AccountSubType.Checking ||
|
||||
.Where(a => a.AccountSubType == AccountSubType.Cash ||
|
||||
a.AccountSubType == AccountSubType.Checking ||
|
||||
a.AccountSubType == AccountSubType.Savings ||
|
||||
a.AccountSubType == AccountSubType.CreditCard)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
|
||||
@@ -41,6 +41,7 @@ public class QuotesController : Controller
|
||||
private readonly IJobPhotoService _jobPhotoService;
|
||||
private readonly IAiUsageLogger _usageLogger;
|
||||
private readonly ICompanyLogoService _logoService;
|
||||
private readonly IInventoryAiLookupService _aiLookupService;
|
||||
|
||||
public QuotesController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -61,7 +62,8 @@ public class QuotesController : Controller
|
||||
IWebHostEnvironment env,
|
||||
IJobPhotoService jobPhotoService,
|
||||
IAiUsageLogger usageLogger,
|
||||
ICompanyLogoService logoService)
|
||||
ICompanyLogoService logoService,
|
||||
IInventoryAiLookupService aiLookupService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
@@ -82,6 +84,7 @@ public class QuotesController : Controller
|
||||
_jobPhotoService = jobPhotoService;
|
||||
_usageLogger = usageLogger;
|
||||
_logoService = logoService;
|
||||
_aiLookupService = aiLookupService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -487,6 +490,8 @@ public class QuotesController : Controller
|
||||
quote.ProspectCity = null;
|
||||
quote.ProspectState = null;
|
||||
quote.ProspectZipCode = null;
|
||||
quote.ProspectSmsConsent = false;
|
||||
quote.ProspectSmsConsentedAt = null;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
@@ -894,6 +899,8 @@ public class QuotesController : Controller
|
||||
quote.QuoteNumber = await GenerateQuoteNumberAsync();
|
||||
quote.PreparedById = currentUser.Id;
|
||||
quote.CompanyId = currentUser.CompanyId;
|
||||
if (dto.ProspectSmsConsent)
|
||||
quote.ProspectSmsConsentedAt = DateTime.UtcNow;
|
||||
|
||||
if (dto.SendEmailToCustomer)
|
||||
{
|
||||
@@ -1001,6 +1008,12 @@ public class QuotesController : Controller
|
||||
for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
|
||||
{
|
||||
var coatDto = itemDto.Coats[coatIndex];
|
||||
|
||||
// If "Add to inventory as Incoming" was checked on the custom tab,
|
||||
// create a 0-balance inventory record so QR codes work on the work order.
|
||||
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
|
||||
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, currentUser.CompanyId);
|
||||
|
||||
var coat = _mapper.Map<QuoteItemCoat>(coatDto);
|
||||
coat.CompanyId = currentUser.CompanyId;
|
||||
|
||||
@@ -1424,6 +1437,12 @@ public class QuotesController : Controller
|
||||
// Update quote entity
|
||||
_mapper.Map(dto, quote);
|
||||
|
||||
// Manage SMS consent timestamp: stamp when first consented, clear when revoked
|
||||
if (dto.ProspectSmsConsent && !quote.ProspectSmsConsentedAt.HasValue)
|
||||
quote.ProspectSmsConsentedAt = DateTime.UtcNow;
|
||||
else if (!dto.ProspectSmsConsent)
|
||||
quote.ProspectSmsConsentedAt = null;
|
||||
|
||||
// Set calculated pricing — snapshot at save time; never recalculate on load
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
@@ -1761,6 +1780,10 @@ public class QuotesController : Controller
|
||||
for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
|
||||
{
|
||||
var coatDto = itemDto.Coats[coatIndex];
|
||||
|
||||
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
|
||||
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, currentUser.CompanyId);
|
||||
|
||||
var coat = _mapper.Map<QuoteItemCoat>(coatDto);
|
||||
coat.CompanyId = currentUser.CompanyId;
|
||||
|
||||
@@ -2116,9 +2139,21 @@ public class QuotesController : Controller
|
||||
var customer = _mapper.Map<Customer>(dto);
|
||||
customer.CompanyId = currentUser!.CompanyId;
|
||||
|
||||
// Carry over SMS consent if staff confirmed it on this form (TCPA compliance)
|
||||
if (dto.SmsConsent)
|
||||
{
|
||||
customer.NotifyBySms = true;
|
||||
customer.SmsConsentedAt = dto.ProspectSmsConsentedAt ?? DateTime.UtcNow;
|
||||
customer.SmsConsentMethod = "verbal";
|
||||
}
|
||||
|
||||
await _unitOfWork.Customers.AddAsync(customer);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Send the TCPA-compliant welcome/opt-in confirmation SMS when consent was granted
|
||||
if (dto.SmsConsent)
|
||||
await _notificationService.NotifySmsConsentGrantedAsync(customer);
|
||||
|
||||
// Get "Converted" status (cached)
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
||||
@@ -2136,6 +2171,8 @@ public class QuotesController : Controller
|
||||
quote.ProspectCity = null;
|
||||
quote.ProspectState = null;
|
||||
quote.ProspectZipCode = null;
|
||||
quote.ProspectSmsConsent = false;
|
||||
quote.ProspectSmsConsentedAt = null;
|
||||
|
||||
// Update status to converted
|
||||
quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId;
|
||||
@@ -2284,6 +2321,8 @@ public class QuotesController : Controller
|
||||
quote.ProspectCity = null;
|
||||
quote.ProspectState = null;
|
||||
quote.ProspectZipCode = null;
|
||||
quote.ProspectSmsConsent = false;
|
||||
quote.ProspectSmsConsentedAt = null;
|
||||
|
||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
@@ -2651,22 +2690,25 @@ public class QuotesController : Controller
|
||||
ViewBag.CompanyTaxPercent = costs.FirstOrDefault()?.TaxPercent ?? 0;
|
||||
}
|
||||
|
||||
// Inventory coatings
|
||||
// Inventory coatings — include incoming items so they can be quoted while powder is in transit
|
||||
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
|
||||
ViewBag.InventoryCoatings = inventory
|
||||
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
|
||||
.OrderBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
|
||||
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
|
||||
.Select(i => new
|
||||
{
|
||||
value = i.Id.ToString(),
|
||||
text = $"{i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)",
|
||||
text = i.IsIncoming
|
||||
? $"[INCOMING] {i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)"
|
||||
: $"{i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)",
|
||||
coverage = i.CoverageSqFtPerLb ?? 30m,
|
||||
efficiency = i.TransferEfficiency ?? 65m,
|
||||
unitOfMeasure = i.UnitOfMeasure ?? "lbs",
|
||||
categoryName = i.InventoryCategory.DisplayName,
|
||||
categoryName = i.InventoryCategory!.DisplayName,
|
||||
costPerLb = i.UnitCost,
|
||||
colorName = i.ColorName ?? i.Name,
|
||||
colorCode = i.ColorCode ?? ""
|
||||
colorCode = i.ColorCode ?? "",
|
||||
isIncoming = i.IsIncoming
|
||||
}).ToList();
|
||||
|
||||
// Vendors
|
||||
@@ -3022,18 +3064,20 @@ public class QuotesController : Controller
|
||||
Description = quote.Description ?? $"Job from Quote {quote.QuoteNumber}",
|
||||
JobStatusId = approvedStatus?.Id ?? 1,
|
||||
JobPriorityId = selectedPriority?.Id ?? 1,
|
||||
QuotedPrice = quote.Total,
|
||||
FinalPrice = quote.Total,
|
||||
CustomerPO = quote.CustomerPO,
|
||||
InternalNotes = quote.Notes, // Copy internal notes from quote
|
||||
IsCustomerApproved = true,
|
||||
IsRushJob = quote.IsRushJob,
|
||||
DiscountType = quote.DiscountType,
|
||||
DiscountValue = quote.DiscountValue,
|
||||
DiscountReason = quote.DiscountReason,
|
||||
CompanyId = quote.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt
|
||||
QuotedPrice = quote.Total,
|
||||
FinalPrice = quote.Total,
|
||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||
CustomerPO = quote.CustomerPO,
|
||||
InternalNotes = quote.Notes, // Copy internal notes from quote
|
||||
IsCustomerApproved = true,
|
||||
IsRushJob = quote.IsRushJob,
|
||||
DiscountType = quote.DiscountType,
|
||||
DiscountValue = quote.DiscountValue,
|
||||
DiscountReason = quote.DiscountReason,
|
||||
CompanyId = quote.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt
|
||||
};
|
||||
|
||||
await _unitOfWork.Jobs.AddAsync(job);
|
||||
@@ -3276,7 +3320,7 @@ public class QuotesController : Controller
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ResendQuote(int id)
|
||||
public async Task<IActionResult> ResendQuote(int id, string? overrideEmail = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -3284,10 +3328,12 @@ public class QuotesController : Controller
|
||||
if (quote == null)
|
||||
return Json(new { success = false, message = "Quote not found." });
|
||||
|
||||
var trimmedOverride = overrideEmail?.Trim();
|
||||
|
||||
// Determine recipient for feedback message
|
||||
string? recipientEmail = quote.CustomerId.HasValue
|
||||
? quote.Customer?.Email
|
||||
: quote.ProspectEmail;
|
||||
string? recipientEmail = !string.IsNullOrWhiteSpace(trimmedOverride)
|
||||
? trimmedOverride
|
||||
: (quote.CustomerId.HasValue ? quote.Customer?.Email : quote.ProspectEmail);
|
||||
|
||||
string recipientName = quote.CustomerId.HasValue && quote.Customer != null
|
||||
? (!string.IsNullOrWhiteSpace(quote.Customer.CompanyName)
|
||||
@@ -3324,7 +3370,7 @@ public class QuotesController : Controller
|
||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename);
|
||||
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename, trimmedOverride);
|
||||
|
||||
// Check the most recent log entry to get actual send status
|
||||
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id);
|
||||
@@ -3743,6 +3789,147 @@ public class QuotesController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 0-balance IsIncoming inventory item from a powder catalog entry so that
|
||||
/// QR codes can be printed on work orders while the powder is still in transit.
|
||||
/// Returns the new inventory item ID, or null if creation fails (non-fatal — the coat
|
||||
/// falls back to custom-powder pricing without an inventory link).
|
||||
/// </summary>
|
||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
|
||||
if (catalogItem == null) return null;
|
||||
|
||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
||||
var coatingCategory = categories
|
||||
.Where(c => c.IsActive && c.IsCoating)
|
||||
.OrderBy(c => c.DisplayOrder)
|
||||
.FirstOrDefault();
|
||||
|
||||
// Match catalog vendor name to a company vendor record
|
||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||
var vendorNameLower = catalogItem.VendorName.ToLower();
|
||||
var matchedVendor = vendors.FirstOrDefault(v =>
|
||||
v.CompanyName.ToLower().Contains(vendorNameLower) ||
|
||||
vendorNameLower.Contains(v.CompanyName.ToLower()));
|
||||
// InventoryCategoryId is nullable — degrade gracefully rather than aborting if the
|
||||
// company has not yet set up inventory categories (e.g., pre-seed).
|
||||
var code = coatingCategory != null
|
||||
? (coatingCategory.CategoryCode.Length >= 4
|
||||
? coatingCategory.CategoryCode[..4].ToUpperInvariant()
|
||||
: coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X'))
|
||||
: "POWD";
|
||||
var prefix = $"{code}-{DateTime.Now:yyMM}-";
|
||||
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
|
||||
var maxSeq = allItems
|
||||
.Where(i => i.SKU.StartsWith(prefix))
|
||||
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
var sku = $"{prefix}{(maxSeq + 1):D4}";
|
||||
|
||||
var name = System.Globalization.CultureInfo.CurrentCulture.TextInfo
|
||||
.ToTitleCase(catalogItem.ColorName.Trim().ToLower());
|
||||
|
||||
// Start with everything the catalog already has, then augment any null
|
||||
// spec fields by fetching the product URL through the AI lookup service.
|
||||
var description = catalogItem.Description;
|
||||
var finish = catalogItem.Finish;
|
||||
var colorFamilies = catalogItem.ColorFamilies;
|
||||
var cureTemp = catalogItem.CureTemperatureF;
|
||||
var cureTime = catalogItem.CureTimeMinutes;
|
||||
var coverage = catalogItem.CoverageSqFtPerLb;
|
||||
var transferEff = catalogItem.TransferEfficiency;
|
||||
var specificGravity = catalogItem.SpecificGravity;
|
||||
var imageUrl = catalogItem.ImageUrl;
|
||||
var sdsUrl = catalogItem.SdsUrl;
|
||||
var tdsUrl = catalogItem.TdsUrl;
|
||||
|
||||
var needsAugment = !string.IsNullOrWhiteSpace(catalogItem.ProductUrl) &&
|
||||
(string.IsNullOrWhiteSpace(description) ||
|
||||
string.IsNullOrWhiteSpace(colorFamilies) ||
|
||||
cureTemp == null || cureTime == null);
|
||||
if (needsAugment)
|
||||
{
|
||||
try
|
||||
{
|
||||
var augmented = await _aiLookupService.LookupByUrlAsync(catalogItem.ProductUrl!, catalogItem.ColorName, catalogItem.TdsUrl);
|
||||
if (augmented.Success)
|
||||
{
|
||||
description = string.IsNullOrWhiteSpace(description) ? augmented.Description : description;
|
||||
finish = string.IsNullOrWhiteSpace(finish) ? augmented.Finish : finish;
|
||||
colorFamilies = string.IsNullOrWhiteSpace(colorFamilies) ? augmented.ColorFamilies : colorFamilies;
|
||||
cureTemp ??= augmented.CureTemperatureF;
|
||||
cureTime ??= augmented.CureTimeMinutes;
|
||||
coverage ??= augmented.CoverageSqFtPerLb;
|
||||
transferEff ??= augmented.TransferEfficiency;
|
||||
specificGravity ??= augmented.SpecificGravity;
|
||||
imageUrl = string.IsNullOrWhiteSpace(imageUrl) ? augmented.ImageUrl : imageUrl;
|
||||
sdsUrl = string.IsNullOrWhiteSpace(sdsUrl) ? augmented.SdsUrl : sdsUrl;
|
||||
tdsUrl = string.IsNullOrWhiteSpace(tdsUrl) ? augmented.TdsUrl : tdsUrl;
|
||||
_logger.LogInformation("AI-augmented incoming inventory item for catalog {CatalogId}", catalogItem.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "AI augment failed for catalog {CatalogId}, continuing with catalog data", catalogItem.Id);
|
||||
}
|
||||
}
|
||||
|
||||
var item = new PowderCoating.Core.Entities.InventoryItem
|
||||
{
|
||||
SKU = sku,
|
||||
Name = name,
|
||||
Description = description,
|
||||
ColorName = catalogItem.ColorName,
|
||||
Manufacturer = catalogItem.VendorName,
|
||||
ManufacturerPartNumber = catalogItem.Sku,
|
||||
Finish = finish,
|
||||
ColorFamilies = colorFamilies,
|
||||
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
|
||||
CoverageSqFtPerLb = coverage ?? 30m,
|
||||
TransferEfficiency = transferEff ?? 65m,
|
||||
CureTemperatureF = cureTemp,
|
||||
CureTimeMinutes = cureTime,
|
||||
SpecificGravity = specificGravity,
|
||||
SpecPageUrl = catalogItem.ProductUrl,
|
||||
ImageUrl = imageUrl,
|
||||
SdsUrl = sdsUrl,
|
||||
TdsUrl = tdsUrl,
|
||||
UnitCost = catalogItem.UnitPrice,
|
||||
AverageCost = catalogItem.UnitPrice,
|
||||
LastPurchasePrice = catalogItem.UnitPrice,
|
||||
QuantityOnHand = 0,
|
||||
UnitOfMeasure = "lbs",
|
||||
PrimaryVendorId = matchedVendor?.Id,
|
||||
InventoryCategoryId = coatingCategory?.Id,
|
||||
Category = coatingCategory?.DisplayName ?? "Powder Coating",
|
||||
IsActive = true,
|
||||
IsIncoming = true,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _unitOfWork.InventoryItems.AddAsync(item);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// Also update the coat DTO so pricing uses the inventory unit cost
|
||||
coatDto.PowderCostPerLb = null; // clear manual price; pricing service reads from inventory
|
||||
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
|
||||
item.Id, item.Name, coatDto.CatalogItemId);
|
||||
|
||||
return item.Id;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
|
||||
coatDto.CatalogItemId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After pricing is determined for an AI item, update the prediction record to flag whether
|
||||
/// the user changed the AI's estimated surface area or unit price before accepting.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Web.Helpers;
|
||||
|
||||
public static class AccountingDisplayHelpers
|
||||
{
|
||||
// Splits at lowercase→uppercase boundaries: "AccountsReceivable" → "Accounts Receivable"
|
||||
private static readonly Regex _camelSplit =
|
||||
new(@"(?<=[a-z])(?=[A-Z])", RegexOptions.Compiled);
|
||||
|
||||
public static string ToDisplayName(this AccountSubType subType) =>
|
||||
_camelSplit.Replace(subType.ToString(), " ");
|
||||
|
||||
public static string ToDisplayName(this AccountType accountType) =>
|
||||
_camelSplit.Replace(accountType.ToString(), " ");
|
||||
}
|
||||
@@ -154,7 +154,7 @@
|
||||
(function () {
|
||||
// SubType enum values → AccountType enum values (mirrors server-side mapping)
|
||||
const subTypeToAccountType = {
|
||||
1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, // Assets
|
||||
8: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, // Assets
|
||||
10: 2, 11: 2, 12: 2, 13: 2, // Liabilities
|
||||
20: 3, 21: 3, // Equity
|
||||
30: 4, 31: 4, 32: 4, // Revenue
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
<script>
|
||||
// Auto-set AccountType when SubType is changed
|
||||
const subTypeToAccountType = {
|
||||
1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, // Asset
|
||||
8: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, // Asset
|
||||
10: 2, 11: 2, 12: 2, 13: 2, // Liability
|
||||
20: 3, 21: 3, // Equity
|
||||
30: 4, 31: 4, 32: 4, // Revenue
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
<span class="badge bg-secondary ms-1" title="System account — cannot be deleted">sys</span>
|
||||
}
|
||||
</td>
|
||||
<td><span class="text-muted small">@acct.AccountSubType</span></td>
|
||||
<td><span class="text-muted small">@acct.AccountSubType.ToDisplayName()</span></td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(acct.ParentAccountName))
|
||||
{
|
||||
|
||||
@@ -29,10 +29,11 @@
|
||||
_ => "bi-journal"
|
||||
};
|
||||
|
||||
string typeLabel = Model.AccountType == AccountType.CostOfGoods ? "Cost of Goods Sold" : Model.AccountType.ToString();
|
||||
string typeLabel = Model.AccountType.ToDisplayName();
|
||||
|
||||
// Derive from AccountSubType (more reliable than AccountType which users can misconfigure)
|
||||
bool normalDebitBalance =
|
||||
Model.AccountSubType == AccountSubType.Cash ||
|
||||
Model.AccountSubType == AccountSubType.Checking ||
|
||||
Model.AccountSubType == AccountSubType.Savings ||
|
||||
Model.AccountSubType == AccountSubType.AccountsReceivable ||
|
||||
@@ -71,7 +72,7 @@
|
||||
<div>
|
||||
<p class="text-muted mb-0">
|
||||
<span class="badge bg-@typeColor bg-opacity-75 me-1">@typeLabel</span>
|
||||
<span class="text-muted small">@Model.AccountSubType · @balanceLabel</span>
|
||||
<span class="text-muted small">@Model.AccountSubType.ToDisplayName() · @balanceLabel</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="ms-auto">
|
||||
|
||||
@@ -92,6 +92,21 @@
|
||||
<p><strong>Contact Name:</strong> @(Model.ProspectContactName ?? "-")</p>
|
||||
<p><strong>Email:</strong> @(Model.ProspectEmail ?? "-")</p>
|
||||
<p><strong>Phone:</strong> @(Model.ProspectPhone ?? "-")</p>
|
||||
<p>
|
||||
<strong>SMS Consent:</strong>
|
||||
@if (Model.ProspectSmsConsent)
|
||||
{
|
||||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Consented</span>
|
||||
@if (Model.ProspectSmsConsentedAt.HasValue)
|
||||
{
|
||||
<span class="text-muted small ms-1">on @Model.ProspectSmsConsentedAt.Value.ToLocalTime().ToString("MM/dd/yyyy")</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small"><i class="bi bi-dash-circle me-1"></i>Not recorded</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>Address:</strong> @(Model.ProspectAddress ?? "-")</p>
|
||||
@@ -1528,6 +1543,8 @@
|
||||
var detHasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail);
|
||||
var detHasMobile = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone);
|
||||
var detHasSmsConsent = Model.CustomerNotifyBySms && detHasMobile;
|
||||
var detProspectHasPhone = Model.IsProspect && !string.IsNullOrWhiteSpace(Model.ProspectPhone);
|
||||
var detProspectSmsReady = detProspectHasPhone && Model.ProspectSmsConsent;
|
||||
}
|
||||
@if (Model.StatusCode != "APPROVED" && Model.StatusCode != "CONVERTED")
|
||||
{
|
||||
@@ -1549,19 +1566,36 @@
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
@if (detHasEmail)
|
||||
@{
|
||||
var detEmailOptedOut = detHasEmail && !Model.CustomerNotifyByEmail;
|
||||
}
|
||||
@if (detEmailOptedOut)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-primary" disabled
|
||||
title="@Model.CustomerName has email notifications turned off">
|
||||
<i class="bi bi-envelope-arrow-up me-1"></i>Send Quote via Email
|
||||
</button>
|
||||
}
|
||||
else if (detHasEmail || !string.IsNullOrWhiteSpace(Model.ProspectEmail))
|
||||
{
|
||||
<button type="button" class="btn btn-outline-primary" onclick="resendQuote(@Model.Id)">
|
||||
<i class="bi bi-envelope-arrow-up me-1"></i>Send Quote via Email
|
||||
</button>
|
||||
}
|
||||
@if (detHasMobile)
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-outline-primary"
|
||||
data-bs-toggle="modal" data-bs-target="#quoteAdHocEmailModal">
|
||||
<i class="bi bi-envelope-arrow-up me-1"></i>Send Quote via Email
|
||||
</button>
|
||||
}
|
||||
@if (detHasMobile || detProspectSmsReady)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-info" onclick="sendQuoteSms(@Model.Id)">
|
||||
<i class="bi bi-chat-dots me-1"></i>Send Quote via SMS
|
||||
</button>
|
||||
}
|
||||
@if (!detHasMobile && !detHasEmail)
|
||||
@if (!detHasMobile && !detHasEmail && !detProspectHasPhone && string.IsNullOrWhiteSpace(Model.ProspectEmail))
|
||||
{
|
||||
<div class="alert alert-warning alert-permanent py-1 px-2 small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>No email or mobile number on file — update the customer record to send this quote electronically.
|
||||
@@ -1571,6 +1605,10 @@
|
||||
{
|
||||
<div class="text-muted small"><i class="bi bi-phone-slash me-1"></i>SMS consent required to send via text.</div>
|
||||
}
|
||||
@if (detProspectHasPhone && !Model.ProspectSmsConsent)
|
||||
{
|
||||
<div class="text-muted small"><i class="bi bi-phone-slash me-1"></i>SMS consent not recorded — edit the quote to enable SMS for this prospect.</div>
|
||||
}
|
||||
@if (!Model.ConvertedToJobId.HasValue)
|
||||
{
|
||||
<form asp-action="ConvertToJob" asp-route-id="@Model.Id" method="post" class="d-inline" id="createJobForm">
|
||||
@@ -2103,6 +2141,30 @@
|
||||
</style>
|
||||
}
|
||||
|
||||
<!-- Ad-hoc Email Modal (no email on file) -->
|
||||
<div class="modal fade" id="quoteAdHocEmailModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-envelope-arrow-up me-2"></i>Send Quote via Email</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-3">No email address is on file for this customer. Enter an address to send to:</p>
|
||||
<label for="quoteAdHocEmailInput" class="form-label fw-medium">Send To</label>
|
||||
<input type="email" id="quoteAdHocEmailInput" class="form-control" placeholder="recipient@example.com" />
|
||||
<div id="quoteAdHocEmailError" class="text-danger small mt-1 d-none"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="sendQuoteToAdHocEmail(@Model.Id)">
|
||||
<i class="bi bi-send me-1"></i>Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Quote via SMS Modal -->
|
||||
<div class="modal fade" id="sendQuoteSmsModal" tabindex="-1" aria-labelledby="sendQuoteSmsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
@@ -2201,7 +2263,20 @@
|
||||
@section Scripts {
|
||||
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
function resendQuote(quoteId) {
|
||||
function sendQuoteToAdHocEmail(quoteId) {
|
||||
const email = (document.getElementById('quoteAdHocEmailInput').value ?? '').trim();
|
||||
const errDiv = document.getElementById('quoteAdHocEmailError');
|
||||
if (!email || !email.includes('@@')) {
|
||||
errDiv.textContent = 'Please enter a valid email address.';
|
||||
errDiv.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
errDiv.classList.add('d-none');
|
||||
bootstrap.Modal.getInstance(document.getElementById('quoteAdHocEmailModal'))?.hide();
|
||||
resendQuote(quoteId, email);
|
||||
}
|
||||
|
||||
function resendQuote(quoteId, overrideEmail) {
|
||||
// Reset modal state
|
||||
document.getElementById('sendQuoteSending').classList.remove('d-none');
|
||||
document.getElementById('sendQuoteResult').classList.add('d-none');
|
||||
@@ -2212,8 +2287,10 @@
|
||||
modal.show();
|
||||
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
const url = '@Url.Action("ResendQuote", "Quotes")?id=' + quoteId
|
||||
+ (overrideEmail ? '&overrideEmail=' + encodeURIComponent(overrideEmail) : '');
|
||||
|
||||
fetch('@Url.Action("ResendQuote", "Quotes")?id=' + quoteId, {
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'RequestVerificationToken': token, 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user