From 9a52e7fae519da379ae98436b21699de34f58a28 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Fri, 8 May 2026 20:48:00 -0400 Subject: [PATCH] Ad-hoc quote email, accounting improvements, AI lookup fix, and misc service updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/settings.local.json | 4 +- TODO.txt | 37 ++- TODO.txt.bak | 35 ++- .../Mappings/QuoteProfile.cs | 4 + .../Services/PdfService.cs | 29 ++- .../Services/PricingCalculationService.cs | 19 +- .../Enums/AccountingEnums.cs | 1 + .../ApplicationDbContextModelSnapshot.cs | 24 +- .../Services/InventoryAiLookupService.cs | 24 +- .../Controllers/AccountsController.cs | 9 +- .../Controllers/BillsController.cs | 6 +- .../Controllers/ExpensesController.cs | 3 +- .../Controllers/QuotesController.cs | 233 ++++++++++++++++-- .../Helpers/AccountingDisplayHelpers.cs | 17 ++ .../Views/Accounts/Create.cshtml | 2 +- .../Views/Accounts/Edit.cshtml | 2 +- .../Views/Accounts/Index.cshtml | 2 +- .../Views/Accounts/Ledger.cshtml | 5 +- .../Views/Quotes/Details.cshtml | 87 ++++++- 19 files changed, 480 insertions(+), 63 deletions(-) create mode 100644 src/PowderCoating.Web/Helpers/AccountingDisplayHelpers.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b369143..a0033bb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -172,7 +172,9 @@ "Bash(Select-Object -First 20)", "PowerShell(node -e \"require\\('fs'\\).existsSync\\(require\\('path'\\).join\\(process.cwd\\(\\), 'node_modules', 'sharp'\\)\\) ? console.log\\('sharp ok'\\) : console.log\\('no sharp'\\)\")", "WebFetch(domain:www.powdercoatinglogix.com)", - "PowerShell($bytes = [System.IO.File]::ReadAllBytes\\('src/PowderCoating.Web/Views/Jobs/Details.cshtml'\\); $text = [System.Text.Encoding]::UTF8.GetString\\($bytes\\); $idx = $text.IndexOf\\('hasPowderData'\\); $snippet = $text.Substring\\($idx - 20, 250\\); [System.Text.Encoding]::Unicode.GetBytes\\($snippet\\) | Format-Hex | Select-Object -First 30)" + "PowerShell($bytes = [System.IO.File]::ReadAllBytes\\('src/PowderCoating.Web/Views/Jobs/Details.cshtml'\\); $text = [System.Text.Encoding]::UTF8.GetString\\($bytes\\); $idx = $text.IndexOf\\('hasPowderData'\\); $snippet = $text.Substring\\($idx - 20, 250\\); [System.Text.Encoding]::Unicode.GetBytes\\($snippet\\) | Format-Hex | Select-Object -First 30)", + "PowerShell($dll = \"C:\\\\Users\\\\spoul\\\\.nuget\\\\packages\\\\questpdf\\\\2024.12.3\\\\lib\\\\net6.0\\\\QuestPDF.dll\"; $asm = [Reflection.Assembly]::LoadFile\\($dll\\); $asm.GetTypes\\(\\) | Where-Object { $_.Name -eq \"ContainerExtensions\" } | ForEach-Object { $_.GetMethods\\(\\) | Where-Object { $_.Name -match \"Canvas|Rotat|Layer\" } | Select-Object Name } | Sort-Object Name -Unique)", + "PowerShell(Get-ChildItem \"C:\\\\Users\\\\spoul\\\\.nuget\\\\packages\\\\\" -ErrorAction SilentlyContinue | Where-Object { $_.Name -match \"quest|skia\" } | Select-Object Name)" ] } } diff --git a/TODO.txt b/TODO.txt index 0db5bd0..72fe8c3 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,10 +1,14 @@ Shop Management App TO DO List ============================== --Inventory Lookup not always finding price for Columbia Coatings --Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself --Need to allow deleting of powder usage entries, or at least editing in case of a goof up --Still random weird characters on a bunch of pages. Intake button for example on the jobs screen shows: Intake ✓ - +-Add feature to prep for events where we can generate coupons or gift certificates in bulk + +Duplication refactor memory +C:/Users/spoul/.codex/memories/powdercoatingapp-refactor-plan-2026-05-07.md. + +Current memory +C:/Users/spoul/.codex/memories/powdercoatingapp-quote-sync-extracted-2026-05-07.md + + -Google review request email after a job -Check my ChatGPT chat about surface area for a few solid ideas for the system @@ -187,6 +191,29 @@ AI Agent item where we upload a picture and it will calculate the approximate sq -Lookup Modal not showing ALL matches. Maybe make scrollable -Pickup cure information from TDS Sheet if not found by AI Search -ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers +-Inventory Lookup not always finding price for Columbia Coatings +-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself +-Need to allow deleting of powder usage entries, or at least editing in case of a goof up +-Still random weird characters on a bunch of pages. Intake button for example on the jobs screen shows: Intake ✓ + +5/7/2026 +-When editing a job/quote item from catalog, pre-select the item chosen please +-Move buttons to right side of job details page +-When completing a job, pull in powder usage already entered +-Fix invoice due date to match terms selected +-Invoice Status should not show on PDF unless PAID +-If we start with a job, shop supplies is not being added to the items +-If you delete an invoice attached to a job, the create invoice button keeps trying to go back to it +-Customer approval page doesn't show all charges (Oven time missing?) +-Time Logging default user to logged in user +-Add Print Invoice button or allow viewing the PDF +-If an invoice is voided, I cant create a new one from a job. Show voided invoice as history, but allow creating a new one. +-If a completed job is changed after an invoice is created, we need to update the invoice. Also need to be able to modify an invoice to add a discount or similar after it's created +-Add multiple email address for commercial customers (Accounting for invoices and contact for quotes) +-Support entering multiple email addresses (comma seperated) in each field +-If no email on file, then prompt for address to send to. +-When choosing a powder NOT in stock, can we incorporate our inventory lookup function to find a powder, link it to the quote, add it to the inventory with a 0lb balance and still put it on the "powder to order" list? +-When choosing a prospect for a quote, we need way to consent and enable SMS for them Ideas Removed ======================= diff --git a/TODO.txt.bak b/TODO.txt.bak index da255c5..cbdb8e6 100644 --- a/TODO.txt.bak +++ b/TODO.txt.bak @@ -1,8 +1,33 @@ Shop Management App TO DO List ============================== --Inventory Lookup not always finding price for Columbia Coatings --Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself --Need to allow deleting of powder usage entries, or at least editing in case of a goof up +-When editing a job/quote item from catalog, pre-select the item chosen please +-Move buttons to right side of job details page +-When completing a job, pull in powder usage already entered +-Fix invoice due date to match terms selected +-Invoice Status should not show on PDF unless PAID +-If we start with a job, shop supplies is not being added to the items +-If you delete an invoice attached to a job, the create invoice button keeps trying to go back to it +-Customer approval page doesn't show all charges (Oven time missing?) +-Time Logging default user to logged in user +-Add Print Invoice button or allow viewing the PDF +-If an invoice is voided, I cant create a new one from a job. Show voided invoice as history, but allow creating a new one. +-If a completed job is changed after an invoice is created, we need to update the invoice. Also need to be able to modify an invoice to add a discount or similar after it's created +-Add multiple email address for commercial customers (Accounting for invoices and contact for quotes) +-Support entering multiple email addresses (comma seperated) in each field +-If no email on file, then prompt for address to send to. +-When choosing a powder NOT in stock, can we incorporate our inventory lookup function to find a powder, link it to the quote, add it to the inventory with a 0lb balance and still put it on the "powder to order" list? +-When choosing a prospect for a quote, we need way to consent and enable SMS for them + + + + +Duplication refactor memory +C:/Users/spoul/.codex/memories/powdercoatingapp-refactor-plan-2026-05-07.md. + +Current memory +C:/Users/spoul/.codex/memories/powdercoatingapp-quote-sync-extracted-2026-05-07.md + + -Google review request email after a job -Check my ChatGPT chat about surface area for a few solid ideas for the system @@ -185,6 +210,10 @@ AI Agent item where we upload a picture and it will calculate the approximate sq -Lookup Modal not showing ALL matches. Maybe make scrollable -Pickup cure information from TDS Sheet if not found by AI Search -ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers +-Inventory Lookup not always finding price for Columbia Coatings +-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself +-Need to allow deleting of powder usage entries, or at least editing in case of a goof up +-Still random weird characters on a bunch of pages. Intake button for example on the jobs screen shows: Intake ✓ Ideas Removed ======================= diff --git a/src/PowderCoating.Application/Mappings/QuoteProfile.cs b/src/PowderCoating.Application/Mappings/QuoteProfile.cs index bc18feb..e4b845b 100644 --- a/src/PowderCoating.Application/Mappings/QuoteProfile.cs +++ b/src/PowderCoating.Application/Mappings/QuoteProfile.cs @@ -78,6 +78,7 @@ public class QuoteProfile : Profile // CreateQuoteDto -> Quote CreateMap() .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() .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()) diff --git a/src/PowderCoating.Application/Services/PdfService.cs b/src/PowderCoating.Application/Services/PdfService.cs index 5b5008a..17780ee 100644 --- a/src/PowderCoating.Application/Services/PdfService.cs +++ b/src/PowderCoating.Application/Services/PdfService.cs @@ -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 }); } + /// + /// 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. + /// + 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); + } + /// /// 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, diff --git a/src/PowderCoating.Application/Services/PricingCalculationService.cs b/src/PowderCoating.Application/Services/PricingCalculationService.cs index 77c9de0..0e4c39d 100644 --- a/src/PowderCoating.Application/Services/PricingCalculationService.cs +++ b/src/PowderCoating.Application/Services/PricingCalculationService.cs @@ -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) { diff --git a/src/PowderCoating.Core/Enums/AccountingEnums.cs b/src/PowderCoating.Core/Enums/AccountingEnums.cs index 6d303a5..7e0ed48 100644 --- a/src/PowderCoating.Core/Enums/AccountingEnums.cs +++ b/src/PowderCoating.Core/Enums/AccountingEnums.cs @@ -13,6 +13,7 @@ public enum AccountType public enum AccountSubType { // Assets + Cash = 8, Checking = 1, Savings = 2, AccountsReceivable = 3, diff --git a/src/PowderCoating.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/PowderCoating.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index d668f76..19a5e7b 100644 --- a/src/PowderCoating.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/PowderCoating.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2431,6 +2431,9 @@ namespace PowderCoating.Infrastructure.Migrations b.Property("Address") .HasColumnType("nvarchar(max)"); + b.Property("BillingEmail") + .HasColumnType("nvarchar(max)"); + b.Property("City") .HasColumnType("nvarchar(max)"); @@ -3279,6 +3282,9 @@ namespace PowderCoating.Infrastructure.Migrations b.Property("IsDeleted") .HasColumnType("bit"); + b.Property("IsIncoming") + .HasColumnType("bit"); + b.Property("LastPurchaseDate") .HasColumnType("datetime2"); @@ -3844,6 +3850,12 @@ namespace PowderCoating.Infrastructure.Migrations .HasColumnType("uniqueidentifier") .HasDefaultValueSql("NEWID()"); + b.Property("ShopSuppliesAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("ShopSuppliesPercent") + .HasColumnType("decimal(18,2)"); + b.Property("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("ProspectPhone") .HasColumnType("nvarchar(max)"); + b.Property("ProspectSmsConsent") + .HasColumnType("bit"); + + b.Property("ProspectSmsConsentedAt") + .HasColumnType("datetime2"); + b.Property("ProspectState") .HasColumnType("nvarchar(max)"); diff --git a/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs b/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs index 4515c9c..ed21de1 100644 --- a/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs +++ b/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs @@ -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. /// - public async Task LookupByUrlAsync(string url, string? colorName) + public async Task 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) diff --git a/src/PowderCoating.Web/Controllers/AccountsController.cs b/src/PowderCoating.Web/Controllers/AccountsController.cs index 8afc624..7e7d018 100644 --- a/src/PowderCoating.Web/Controllers/AccountsController.cs +++ b/src/PowderCoating.Web/Controllers/AccountsController.cs @@ -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() - .Select(t => new SelectListItem(t.ToString(), ((int)t).ToString())) + .Select(t => new SelectListItem(t.ToDisplayName(), ((int)t).ToString())) .ToList(); ViewBag.AccountSubTypes = Enum.GetValues() - .Select(t => new SelectListItem(t.ToString(), ((int)t).ToString())) + .Select(t => new SelectListItem(t.ToDisplayName(), ((int)t).ToString())) .ToList(); } } diff --git a/src/PowderCoating.Web/Controllers/BillsController.cs b/src/PowderCoating.Web/Controllers/BillsController.cs index 8d66b2d..71e61e1 100644 --- a/src/PowderCoating.Web/Controllers/BillsController.cs +++ b/src/PowderCoating.Web/Controllers/BillsController.cs @@ -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) diff --git a/src/PowderCoating.Web/Controllers/ExpensesController.cs b/src/PowderCoating.Web/Controllers/ExpensesController.cs index a8ca0c8..9d65386 100644 --- a/src/PowderCoating.Web/Controllers/ExpensesController.cs +++ b/src/PowderCoating.Web/Controllers/ExpensesController.cs @@ -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) diff --git a/src/PowderCoating.Web/Controllers/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs index 49aae0a..91ecaad 100644 --- a/src/PowderCoating.Web/Controllers/QuotesController.cs +++ b/src/PowderCoating.Web/Controllers/QuotesController.cs @@ -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; } /// @@ -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(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(coatDto); coat.CompanyId = currentUser.CompanyId; @@ -2116,9 +2139,21 @@ public class QuotesController : Controller var customer = _mapper.Map(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 /// [HttpPost] [ValidateAntiForgeryToken] - public async Task ResendQuote(int id) + public async Task 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 } } + /// + /// 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). + /// + private async Task 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; + } + } + /// /// 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. diff --git a/src/PowderCoating.Web/Helpers/AccountingDisplayHelpers.cs b/src/PowderCoating.Web/Helpers/AccountingDisplayHelpers.cs new file mode 100644 index 0000000..b39be7d --- /dev/null +++ b/src/PowderCoating.Web/Helpers/AccountingDisplayHelpers.cs @@ -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(), " "); +} diff --git a/src/PowderCoating.Web/Views/Accounts/Create.cshtml b/src/PowderCoating.Web/Views/Accounts/Create.cshtml index ec6b194..a68d647 100644 --- a/src/PowderCoating.Web/Views/Accounts/Create.cshtml +++ b/src/PowderCoating.Web/Views/Accounts/Create.cshtml @@ -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 diff --git a/src/PowderCoating.Web/Views/Accounts/Edit.cshtml b/src/PowderCoating.Web/Views/Accounts/Edit.cshtml index abb1039..7c6c4b7 100644 --- a/src/PowderCoating.Web/Views/Accounts/Edit.cshtml +++ b/src/PowderCoating.Web/Views/Accounts/Edit.cshtml @@ -144,7 +144,7 @@