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:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
=======================
|
||||
|
||||
+32
-3
@@ -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
|
||||
=======================
|
||||
|
||||
@@ -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