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:
@@ -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