Merge dev into master: billing email, SMS consent, incoming powder, invoice/job fixes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 21:13:54 -04:00
64 changed files with 40532 additions and 277 deletions
+3 -1
View File
@@ -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)"
]
}
}
+32 -5
View File
@@ -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
View File
@@ -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
=======================
@@ -9,6 +9,7 @@ public class CustomerDto
public string? ContactFirstName { get; set; }
public string? ContactLastName { get; set; }
public string? Email { get; set; }
public string? BillingEmail { get; set; }
public string? Phone { get; set; }
public string? MobilePhone { get; set; }
public string? Address { get; set; }
@@ -52,10 +53,13 @@ public class CreateCustomerDto : IValidatableObject
public string? ContactLastName { get; set; }
[Display(Name = "Email")]
[EmailAddress(ErrorMessage = "Please enter a valid email address")]
[StringLength(200)]
[StringLength(1000)]
public string? Email { get; set; }
[Display(Name = "Billing / Accounting Email")]
[StringLength(1000)]
public string? BillingEmail { get; set; }
[Display(Name = "Phone")]
[Phone(ErrorMessage = "Please enter a valid phone number")]
[StringLength(20)]
@@ -143,6 +147,33 @@ public class CreateCustomerDto : IValidatableObject
"Please provide at least one contact method (Email or Phone)",
new[] { nameof(Email), nameof(Phone) });
}
// Validate each address in comma-separated email fields
foreach (var addr in SplitEmails(Email))
{
if (!IsValidEmail(addr))
yield return new ValidationResult(
$"'{addr}' is not a valid email address.",
new[] { nameof(Email) });
}
foreach (var addr in SplitEmails(BillingEmail))
{
if (!IsValidEmail(addr))
yield return new ValidationResult(
$"'{addr}' is not a valid email address.",
new[] { nameof(BillingEmail) });
}
}
private static IEnumerable<string> SplitEmails(string? value) =>
string.IsNullOrWhiteSpace(value)
? []
: value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private static bool IsValidEmail(string email)
{
try { _ = new System.Net.Mail.MailAddress(email); return true; }
catch { return false; }
}
}
@@ -43,6 +43,7 @@ public class InventoryItemDto
public string? Location { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
public bool IsIncoming { get; set; }
public DateTime? DiscontinuedDate { get; set; }
public DateTime CreatedAt { get; set; }
public bool IsLowStock { get; set; }
@@ -74,6 +75,7 @@ public class InventoryListDto
public int? PrimaryVendorId { get; set; }
public string? PrimaryVendorName { get; set; }
public bool IsActive { get; set; }
public bool IsIncoming { get; set; }
public bool IsLowStock { get; set; }
public bool IsOutOfStock { get; set; }
public bool HasSamplePanel { get; set; }
@@ -217,6 +219,9 @@ public class CreateInventoryItemDto
[Display(Name = "Sample Panel on Wall")]
public bool HasSamplePanel { get; set; }
[Display(Name = "Incoming / On Order")]
public bool IsIncoming { get; set; }
}
public class UpdateInventoryItemDto : CreateInventoryItemDto
@@ -45,6 +45,12 @@ public class JobDto
public decimal QuotedPrice { get; set; }
public decimal FinalPrice { get; set; }
public decimal ShopSuppliesAmount { get; set; }
public decimal ShopSuppliesPercent { get; set; }
public bool IsRushJob { get; set; }
public string DiscountType { get; set; } = "None";
public decimal DiscountValue { get; set; }
public string? DiscountReason { get; set; }
public string? CustomerPO { get; set; }
public string? SpecialInstructions { get; set; }
public string? InternalNotes { get; set; }
@@ -59,6 +59,8 @@ public class QuoteDto
public string? ProspectCity { get; set; }
public string? ProspectState { get; set; }
public string? ProspectZipCode { get; set; }
public bool ProspectSmsConsent { get; set; }
public DateTime? ProspectSmsConsentedAt { get; set; }
public string? PreparedById { get; set; }
public string? PreparedByName { get; set; }
@@ -186,6 +188,9 @@ public class CreateQuoteDto
[StringLength(10)]
public string? ProspectZipCode { get; set; }
[Display(Name = "SMS Consent")]
public bool ProspectSmsConsent { get; set; } = false;
// Oven Selection
[Display(Name = "Oven")]
public int? OvenCostId { get; set; }
@@ -322,6 +327,9 @@ public class UpdateQuoteDto
[StringLength(10)]
public string? ProspectZipCode { get; set; }
[Display(Name = "SMS Consent")]
public bool ProspectSmsConsent { get; set; } = false;
// Oven Selection
[Display(Name = "Oven")]
public int? OvenCostId { get; set; }
@@ -685,6 +693,16 @@ public class ConvertQuoteToCustomerDto
[Display(Name = "Notes")]
[DataType(DataType.MultilineText)]
public string? Notes { get; set; }
/// <summary>
/// Staff must explicitly confirm verbal SMS consent before it carries over to the new customer record.
/// Pre-checked when ProspectSmsConsent was true on the source quote.
/// </summary>
[Display(Name = "Customer has given verbal consent to receive SMS notifications")]
public bool SmsConsent { get; set; }
/// <summary>Timestamp from the source quote — preserved so the consent record reflects when consent was originally given.</summary>
public DateTime? ProspectSmsConsentedAt { get; set; }
}
// ============================================================================
@@ -745,6 +763,16 @@ public class CreateQuoteItemCoatDto
/// When true, the additional layer labor charge is not applied even if this is not the first coat.
/// </summary>
public bool NoExtraLayerCharge { get; set; }
/// <summary>Platform powder catalog item ID selected via the Custom tab lookup.</summary>
public int? CatalogItemId { get; set; }
/// <summary>
/// When true (and CatalogItemId is set), the server creates a 0-balance IsIncoming inventory
/// item from the catalog entry so QR codes can be printed while the powder is in transit.
/// The coat is then linked to that new inventory record.
/// </summary>
public bool AddAsIncoming { get; set; }
}
/// <summary>
@@ -47,8 +47,10 @@ public interface IInventoryAiLookupService
/// <summary>
/// Fetch cure specs, color families, finish, and clear-coat data from a known product URL.
/// Skips the Serper search step; used after a catalog hit to augment catalog fields.
/// When <paramref name="tdsFallbackUrl"/> is supplied and cure specs are still null after
/// the main fetch, the TDS page is tried automatically before returning.
/// </summary>
Task<InventoryAiLookupResult> LookupByUrlAsync(string url, string? colorName);
Task<InventoryAiLookupResult> LookupByUrlAsync(string url, string? colorName, string? tdsFallbackUrl = null);
/// <summary>
/// Read a powder label photo and extract manufacturer, color name, SKU, and cure specs
@@ -9,7 +9,7 @@ public interface INotificationService
/// Notify when a quote is created/sent. Handles both registered customers and prospects.
/// Optionally attaches the quote PDF to the email.
/// </summary>
Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null);
Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null, string? overrideEmail = null);
/// <summary>
/// Sends the quote approval link to the customer via SMS.
@@ -58,7 +58,7 @@ public interface INotificationService
/// Notify customer when an invoice has been sent.
/// Optionally includes an online payment link in the email body.
/// </summary>
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null);
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null);
/// <summary>
/// Notify customer (internal) when a payment has been recorded on an invoice.
@@ -24,7 +24,9 @@ public class InvoiceProfile : Profile
? s.Customer.CompanyName
: $"{s.Customer.ContactFirstName} {s.Customer.ContactLastName}".Trim())
: string.Empty))
.ForMember(d => d.CustomerEmail, o => o.MapFrom(s => s.Customer != null ? s.Customer.Email : null))
.ForMember(d => d.CustomerEmail, o => o.MapFrom(s => s.Customer != null
? (s.Customer.BillingEmail ?? s.Customer.Email)
: null))
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
@@ -52,6 +52,7 @@ public class JobProfile : Profile
.ForMember(dest => dest.PrepServiceIds, opt => opt.MapFrom(src =>
src.JobPrepServices.Select(jps => jps.PrepServiceId).ToList()))
.ForMember(dest => dest.TimeEntries, opt => opt.MapFrom(src => src.TimeEntries))
.ForMember(dest => dest.DiscountType, opt => opt.MapFrom(src => src.DiscountType.ToString()))
.ForMember(dest => dest.IsReworkJob, opt => opt.MapFrom(src => src.IsReworkJob))
.ForMember(dest => dest.OriginalJobId, opt => opt.MapFrom(src => src.OriginalJobId))
.ForMember(dest => dest.OriginalJobNumber,
@@ -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)
{
@@ -6,6 +6,7 @@ public class Customer : BaseEntity
public string? ContactFirstName { get; set; }
public string? ContactLastName { get; set; }
public string? Email { get; set; }
public string? BillingEmail { get; set; } // Accounting/invoicing email for commercial customers
public string? Phone { get; set; }
public string? MobilePhone { get; set; }
public string? Address { get; set; }
@@ -58,6 +58,13 @@ public class InventoryItem : BaseEntity
public bool IsActive { get; set; } = true;
public DateTime? DiscontinuedDate { get; set; }
/// <summary>
/// True when this item was added to inventory as an ordered-but-not-yet-received powder.
/// Staff can quote and print QR codes while the powder is in transit.
/// Cleared automatically when a Purchase receipt is posted or staff manually unchecks it.
/// </summary>
public bool IsIncoming { get; set; } = false;
// ── Financial Account Mapping ──────────────────────────────────────────
/// <summary>
+2
View File
@@ -28,6 +28,8 @@ public class Job : BaseEntity
// Pricing
public decimal QuotedPrice { get; set; }
public decimal FinalPrice { get; set; }
public decimal ShopSuppliesAmount { get; set; }
public decimal ShopSuppliesPercent { get; set; }
// Discount & rush (mirrors quote fields; preserved through quote→job conversion and job edits)
public bool IsRushJob { get; set; }
+3
View File
@@ -17,6 +17,9 @@ public class Quote : BaseEntity
public string? ProspectCity { get; set; }
public string? ProspectState { get; set; }
public string? ProspectZipCode { get; set; }
// TCPA compliance: only true when staff explicitly records verbal SMS consent
public bool ProspectSmsConsent { get; set; } = false;
public DateTime? ProspectSmsConsentedAt { get; set; }
// Lookup foreign key (replacing enum)
public int QuoteStatusId { get; set; }
@@ -13,6 +13,7 @@ public enum AccountType
public enum AccountSubType
{
// Assets
Cash = 8,
Checking = 1,
Savings = 2,
AccountsReceivable = 3,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCustomerBillingEmail : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BillingEmail",
table: "Customers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(648));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(653));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(655));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BillingEmail",
table: "Customers");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(846));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(852));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(853));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddProspectSmsConsent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "ProspectSmsConsent",
table: "Quotes",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "ProspectSmsConsentedAt",
table: "Quotes",
type: "datetime2",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5347));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5357));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5358));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProspectSmsConsent",
table: "Quotes");
migrationBuilder.DropColumn(
name: "ProspectSmsConsentedAt",
table: "Quotes");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(648));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(653));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(655));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddInventoryIsIncoming : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsIncoming",
table: "InventoryItems",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1857));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1863));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1865));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsIncoming",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5347));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5357));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5358));
}
}
}
@@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddShopSuppliesAmountToJob : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "ShopSuppliesAmount",
table: "Jobs",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "ShopSuppliesPercent",
table: "Jobs",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4358));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4424));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4426));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ShopSuppliesAmount",
table: "Jobs");
migrationBuilder.DropColumn(
name: "ShopSuppliesPercent",
table: "Jobs");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1857));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1863));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1865));
}
}
}
@@ -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)
@@ -70,7 +70,7 @@ public class NotificationService : INotificationService
/// - Customer: respects NotifyByEmail; still writes a Skipped log if opted out.
/// Always writes a NotificationLog row so the Notifications Sent tab shows delivery history.
/// </summary>
public async Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null)
public async Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null, string? overrideEmail = null)
{
try
{
@@ -80,7 +80,8 @@ public class NotificationService : INotificationService
// Prospect quote (no customer record yet)
if (quote.CustomerId == null)
{
if (string.IsNullOrWhiteSpace(quote.ProspectEmail))
var prospectEmail = !string.IsNullOrWhiteSpace(overrideEmail) ? overrideEmail : quote.ProspectEmail;
if (string.IsNullOrWhiteSpace(prospectEmail))
return;
var prospectName = !string.IsNullOrWhiteSpace(quote.ProspectContactName)
@@ -97,7 +98,7 @@ public class NotificationService : INotificationService
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
quote.ProspectEmail, prospectName, subject, plainText, fullHtml,
prospectEmail, prospectName, subject, plainText, fullHtml,
pdfAttachment, pdfFilename, "application/pdf",
replyToEmail, replyToName);
@@ -107,7 +108,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.QuoteSent,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = prospectName,
Recipient = quote.ProspectEmail,
Recipient = prospectEmail,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -124,7 +125,11 @@ public class NotificationService : INotificationService
var customerName = GetCustomerDisplayName(customer);
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
// Override address (ad-hoc staff entry) takes priority over customer record.
var emailToUse = !string.IsNullOrWhiteSpace(overrideEmail) ? overrideEmail : customer.Email;
var quoteEmails = ParseEmailList(emailToUse);
// Bypass NotifyByEmail preference when staff explicitly supplies an override address.
if ((customer.NotifyByEmail || !string.IsNullOrWhiteSpace(overrideEmail)) && quoteEmails.Count > 0)
{
var baseUrl = await GetBaseUrlAsync();
var values = BuildQuoteSentValues(companyName, customerName, quote, baseUrl);
@@ -135,8 +140,8 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl);
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
customer.Email, customerName, subject, plainText, fullHtml,
var (success, error, recipientsLog) = await SendToEmailListAsync(
emailToUse, customerName, subject, plainText, fullHtml,
pdfAttachment, pdfFilename, "application/pdf",
replyToEmail, replyToName);
@@ -146,7 +151,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.QuoteSent,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Recipient = recipientsLog,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -156,10 +161,10 @@ public class NotificationService : INotificationService
CompanyId = quote.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
else if (quoteEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.QuoteSent,
customerName, customer.Email, quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
customerName, string.Join(", ", quoteEmails), quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
}
}
catch (Exception ex)
@@ -195,7 +200,9 @@ public class NotificationService : INotificationService
if (quote.CustomerId == null)
{
// Prospect — use ProspectPhone; no opt-in check (they explicitly provided a phone)
// Prospect — requires explicit staff-recorded consent (TCPA compliance)
if (!quote.ProspectSmsConsent)
return (false, "SMS consent has not been recorded for this prospect. Edit the quote to record verbal consent before sending via SMS.");
smsPhone = quote.ProspectPhone;
if (string.IsNullOrWhiteSpace(smsPhone))
return (false, "No phone number on file for this prospect.");
@@ -279,7 +286,8 @@ public class NotificationService : INotificationService
var (replyToEmail, replyToName) = await GetEmailFromAsync(quote.CompanyId);
var customerName = GetCustomerDisplayName(customer);
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
var approvedEmails = ParseEmailList(customer.Email);
if (customer.NotifyByEmail && approvedEmails.Count > 0)
{
var values = new Dictionary<string, string>
{
@@ -295,7 +303,7 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
@@ -305,7 +313,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.QuoteApproved,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Recipient = recipientsLog,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -315,10 +323,10 @@ public class NotificationService : INotificationService
CompanyId = quote.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
else if (approvedEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.QuoteApproved,
customerName, customer.Email, quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
customerName, string.Join(", ", approvedEmails), quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
}
}
catch (Exception ex)
@@ -349,7 +357,8 @@ public class NotificationService : INotificationService
: NotificationType.JobStatusChanged;
// Email for all status changes
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
var statusEmails = ParseEmailList(customer.Email);
if (customer.NotifyByEmail && statusEmails.Count > 0)
{
// ScheduledDate is when the shop plans to complete the work;
// DueDate is the customer deadline — fall back to it only when no scheduled date is set.
@@ -377,7 +386,7 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
@@ -387,7 +396,7 @@ public class NotificationService : INotificationService
NotificationType = notifType,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Recipient = recipientsLog,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -397,10 +406,10 @@ public class NotificationService : INotificationService
CompanyId = job.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
else if (statusEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.JobStatusChanged,
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
customerName, string.Join(", ", statusEmails), job.CompanyId, customerId: customer.Id, jobId: job.Id));
}
}
@@ -427,7 +436,8 @@ public class NotificationService : INotificationService
var customerName = GetCustomerDisplayName(customer);
// Email
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
var completedEmails = ParseEmailList(customer.Email);
if (customer.NotifyByEmail && completedEmails.Count > 0)
{
var values = new Dictionary<string, string>
{
@@ -444,7 +454,7 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
@@ -454,7 +464,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.JobCompleted,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Recipient = recipientsLog,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -464,10 +474,10 @@ public class NotificationService : INotificationService
CompanyId = job.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
else if (completedEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.JobCompleted,
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
customerName, string.Join(", ", completedEmails), job.CompanyId, customerId: customer.Id, jobId: job.Id));
}
// SMS — skip when the caller (Admin/Manager) will handle it via the compose modal
@@ -611,7 +621,7 @@ public class NotificationService : INotificationService
/// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a
/// standard "here is your invoice" message with no payment CTA.
/// </summary>
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null)
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null)
{
try
{
@@ -622,7 +632,15 @@ public class NotificationService : INotificationService
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
var customerName = GetCustomerDisplayName(customer);
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
// Override email (staff-provided ad-hoc address) takes priority over customer record.
// Use BillingEmail when set (commercial accounting dept); fall back to primary Email.
var invoiceEmail = !string.IsNullOrWhiteSpace(overrideEmail)
? overrideEmail
: (!string.IsNullOrWhiteSpace(customer.BillingEmail) ? customer.BillingEmail : customer.Email);
var invoiceEmails = ParseEmailList(invoiceEmail);
// Bypass NotifyByEmail preference when staff explicitly supplies an override address.
if ((customer.NotifyByEmail || !string.IsNullOrWhiteSpace(overrideEmail)) && invoiceEmails.Count > 0)
{
var dueText = invoice.DueDate.HasValue
? $" Payment is due by {invoice.DueDate.Value:MMMM d, yyyy}."
@@ -661,8 +679,8 @@ public class NotificationService : INotificationService
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
: StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
customer.Email, customerName, subject, plainText, fullHtml,
var (success, error, recipientsLog) = await SendToEmailListAsync(
invoiceEmail, customerName, subject, plainText, fullHtml,
pdfAttachment, pdfFilename, "application/pdf",
replyToEmail, replyToName);
@@ -672,7 +690,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.InvoiceSent,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Recipient = recipientsLog,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -682,10 +700,10 @@ public class NotificationService : INotificationService
CompanyId = invoice.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
else if (invoiceEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
customerName, customer.Email, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
customerName, string.Join(", ", invoiceEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
}
}
catch (Exception ex)
@@ -710,7 +728,8 @@ public class NotificationService : INotificationService
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
var customerName = GetCustomerDisplayName(customer);
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
var paymentEmails = ParseEmailList(customer.Email);
if (customer.NotifyByEmail && paymentEmails.Count > 0)
{
var balanceText = invoice.BalanceDue > 0
? $" Remaining balance: {invoice.BalanceDue:C}."
@@ -733,7 +752,7 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
@@ -743,7 +762,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.PaymentReceived,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Recipient = recipientsLog,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -753,10 +772,10 @@ public class NotificationService : INotificationService
CompanyId = invoice.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
else if (paymentEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.PaymentReceived,
customerName, customer.Email, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
customerName, string.Join(", ", paymentEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
}
}
catch (Exception ex)
@@ -782,7 +801,8 @@ public class NotificationService : INotificationService
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
var customerName = GetCustomerDisplayName(customer);
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
var reminderEmails = ParseEmailList(customer.Email);
if (customer.NotifyByEmail && reminderEmails.Count > 0)
{
var dueDate = invoice.DueDate.HasValue
? invoice.DueDate.Value.ToString("MMMM d, yyyy")
@@ -806,7 +826,7 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
@@ -816,7 +836,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.PaymentReminder,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Recipient = recipientsLog,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -826,10 +846,10 @@ public class NotificationService : INotificationService
CompanyId = invoice.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
else if (reminderEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.PaymentReminder,
customerName, customer.Email, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
customerName, string.Join(", ", reminderEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
}
}
catch (Exception ex)
@@ -1324,7 +1344,7 @@ public class NotificationService : INotificationService
{
var customer = invoice.Customer
?? await _context.Customers.AsNoTracking().FirstOrDefaultAsync(c => c.Id == invoice.CustomerId);
if (customer == null || string.IsNullOrWhiteSpace(customer.Email)) return;
if (customer == null || ParseEmailList(customer.Email).Count == 0) return;
var customerName = GetCustomerDisplayName(customer);
var company = await _context.Companies.AsNoTracking()
@@ -1374,7 +1394,7 @@ Thank you for your business,
<p style='color:#6b7280;font-size:14px;'>Thank you for your business,<br/><strong>{System.Net.WebUtility.HtmlEncode(companyName)}</strong></p>
</div>";
await _emailService.SendEmailAsync(customer.Email, customerName, subject, plain, html,
await SendToEmailListAsync(customer.Email, customerName, subject, plain, html,
replyToEmail: replyToEmail, replyToName: replyToName);
}
catch (Exception ex)
@@ -1389,7 +1409,7 @@ Thank you for your business,
{
// Determine recipient — linked customer or prospect contact
string? toEmail = quote.Customer?.Email ?? quote.ProspectEmail;
if (string.IsNullOrWhiteSpace(toEmail)) return;
if (ParseEmailList(toEmail).Count == 0) return;
string customerName;
if (quote.Customer != null)
@@ -1448,7 +1468,7 @@ Thank you for your business,
<p style='color:#6b7280;font-size:14px;'>Thank you for your business,<br/><strong>{System.Net.WebUtility.HtmlEncode(companyName)}</strong></p>
</div>";
await _emailService.SendEmailAsync(toEmail, customerName, subject, plain, html,
await SendToEmailListAsync(toEmail, customerName, subject, plain, html,
replyToEmail: replyToEmail, replyToName: replyToName);
}
catch (Exception ex)
@@ -1500,6 +1520,52 @@ Log in to your Stripe Dashboard to respond to this dispute. You typically have 7
}
}
/// <summary>
/// Parses a comma-separated email field into individual, trimmed addresses.
/// Silently ignores blank entries; does not validate format beyond requiring '@'.
/// </summary>
private static List<string> ParseEmailList(string? emailField)
{
if (string.IsNullOrWhiteSpace(emailField)) return [];
return emailField
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(e => e.Contains('@'))
.ToList();
}
/// <summary>
/// Sends the same email to every address in a comma-separated <paramref name="emailList"/>.
/// Returns (anySuccess, lastError, comma-joined recipient string for logging).
/// </summary>
private async Task<(bool Success, string? ErrorMessage, string RecipientsLog)> SendToEmailListAsync(
string? emailList,
string toName,
string subject,
string plainText,
string? htmlBody = null,
byte[]? attachmentData = null,
string? attachmentFilename = null,
string? attachmentContentType = null,
string? replyToEmail = null,
string? replyToName = null)
{
var emails = ParseEmailList(emailList);
if (emails.Count == 0) return (false, "No valid email addresses", string.Empty);
bool anySuccess = false;
string? lastError = null;
foreach (var email in emails)
{
var (ok, err) = await _emailService.SendEmailAsync(
email, toName, subject, plainText, htmlBody,
attachmentData, attachmentFilename, attachmentContentType,
replyToEmail, replyToName);
if (ok) anySuccess = true;
else lastError = err;
}
return (anySuccess, anySuccess ? null : lastError, string.Join(", ", emails));
}
private static string GetCustomerDisplayName(Customer customer)
{
if (!customer.IsCommercial)
@@ -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)
@@ -708,7 +708,6 @@ public class InventoryController : Controller
return Json(new { success = false, errorMessage = "No product URL provided." });
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
if (result.Success) await ApplyTdsCureFallbackAsync(result, colorName);
return Json(result);
}
@@ -801,17 +800,15 @@ public class InventoryController : Controller
}
/// <summary>
/// If cure temperature or cure time is still missing after the primary lookup but a TDS URL
/// was returned, fetches that page and asks Claude to extract only the cure schedule.
/// Mutates <paramref name="result"/> in place; silently no-ops on failure so callers
/// can always return the result even if the TDS fetch does not help.
/// When cure specs are still missing after a primary AI lookup (LookupAsync or ScanLabelAsync),
/// fetches the TDS URL that Claude returned and asks it to extract only the cure schedule.
/// Not used by AiAugmentFromUrl — that path uses LookupByUrlAsync which has TDS fallback built in.
/// </summary>
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
{
if ((result.CureTemperatureF == null || result.CureTimeMinutes == null)
&& !string.IsNullOrEmpty(result.TdsUrl))
{
_logger.LogInformation("Cure specs missing after lookup; trying TDS at {Url}", result.TdsUrl);
var tds = await _aiLookupService.FetchTdsCureSpecsAsync(result.TdsUrl, colorName);
if (tds.Success)
{
@@ -1118,6 +1115,109 @@ public class InventoryController : Controller
return Json(results);
}
/// <summary>
/// Creates a 0-balance inventory item from a PowderCatalogItem record and marks it IsIncoming=true.
/// Called by the item wizard when a staff member needs to quote a powder that has been ordered
/// but not yet received — the inventory record enables QR code printing on the work order.
/// Returns the new item's data in the same shape as the inventoryPowdersData list so the wizard
/// can add it to powderData and select it immediately without a page refresh.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreateIncomingFromCatalog(int catalogItemId)
{
try
{
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
if (catalogItem == null)
return Json(new { success = false, error = "Catalog item not found." });
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Find the default coating category to assign
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
if (coatingCategory == null)
return Json(new { success = false, error = "No active coating category found. Please configure inventory categories first." });
// Generate a unique SKU following the same pattern as GenerateSku: {CODE}-{YYMM}-{####}
var code = coatingCategory.CategoryCode.Length >= 4
? coatingCategory.CategoryCode[..4].ToUpperInvariant()
: coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X');
var yearMonth = DateTime.Now.ToString("yyMM");
var prefix = $"{code}-{yearMonth}-";
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 item = new InventoryItem
{
SKU = sku,
Name = ToTitleCase($"{catalogItem.VendorName} {catalogItem.ColorName}"),
ColorName = catalogItem.ColorName,
Manufacturer = catalogItem.VendorName,
ManufacturerPartNumber= catalogItem.Sku,
Finish = catalogItem.Finish,
ColorFamilies = catalogItem.ColorFamilies,
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
CoverageSqFtPerLb = catalogItem.CoverageSqFtPerLb ?? 30m,
TransferEfficiency = GetEffectiveTransferEfficiency(catalogItem.TransferEfficiency),
CureTemperatureF = catalogItem.CureTemperatureF,
CureTimeMinutes = catalogItem.CureTimeMinutes,
SpecificGravity = catalogItem.SpecificGravity,
SpecPageUrl = catalogItem.ProductUrl,
ImageUrl = catalogItem.ImageUrl,
SdsUrl = catalogItem.SdsUrl,
TdsUrl = catalogItem.TdsUrl,
UnitCost = catalogItem.UnitPrice,
AverageCost = catalogItem.UnitPrice,
LastPurchasePrice = catalogItem.UnitPrice,
QuantityOnHand = 0,
UnitOfMeasure = "lbs",
InventoryCategoryId = coatingCategory.Id,
Category = coatingCategory.DisplayName,
IsActive = true,
IsIncoming = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow,
};
await _unitOfWork.InventoryItems.AddAsync(item);
await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("Created incoming inventory item {ItemId} ({ItemName}) from catalog item {CatalogId} for company {CompanyId}",
item.Id, item.Name, catalogItemId, companyId);
return Json(new
{
success = true,
value = item.Id.ToString(),
text = $"[INCOMING] {coatingCategory.DisplayName} - {item.Manufacturer ?? "Generic"} - {item.ColorName ?? item.Name} - {item.ManufacturerPartNumber ?? "N/A"} ({item.UnitCost:C4}/unit)",
coverage = item.CoverageSqFtPerLb ?? 30m,
efficiency = item.TransferEfficiency ?? 65m,
unitOfMeasure= item.UnitOfMeasure,
categoryName = coatingCategory.DisplayName,
costPerLb = item.UnitCost,
colorName = item.ColorName ?? item.Name,
colorCode = "",
isIncoming = true
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create incoming inventory item from catalog {CatalogItemId}", catalogItemId);
return Json(new { success = false, error = "Failed to create inventory item. Please try again." });
}
}
private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
{
return transferEfficiency ?? DefaultTransferEfficiency;
@@ -51,6 +51,30 @@ public class InvoicesController : Controller
_logoService = logoService;
}
private static readonly string[] StandardPaymentTerms =
[
"Due on Receipt",
"Net 15",
"Net 30",
"Net 45",
"Net 60",
"Net 90",
"2% 10 Net 30",
"COD",
];
/// <summary>
/// Builds the payment terms SelectList for Create/Edit views. Always includes the provided
/// <paramref name="selectedTerm"/> even if it is a custom value not in the standard list.
/// </summary>
private static SelectList BuildPaymentTermsSelectList(string? selectedTerm)
{
var terms = StandardPaymentTerms.ToList();
if (!string.IsNullOrWhiteSpace(selectedTerm) && !terms.Contains(selectedTerm, StringComparer.OrdinalIgnoreCase))
terms.Insert(0, selectedTerm);
return new SelectList(terms, selectedTerm);
}
// -----------------------------------------------------------------------
// GET: /Invoices
// -----------------------------------------------------------------------
@@ -328,9 +352,9 @@ public class InvoicesController : Controller
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems);
if (job == null) return NotFound();
// Validate no existing invoice for this job
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value, includeDeleted: true);
if (existing != null)
// Validate no existing active invoice for this job (voided ones are kept as history)
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value);
if (existing != null && existing.Status != InvoiceStatus.Voided)
return RedirectToAction(nameof(Details), new { id = existing.Id });
dto.JobId = job.Id;
@@ -383,12 +407,15 @@ public class InvoicesController : Controller
});
}
// Track whether there were real job items before any fallback
bool hadJobItems = dto.InvoiceItems.Any();
// If no job items, use job final price as single line.
// FinalPrice is always the post-tax total (set by the pricing engine or imported from
// an export). Treat it as the agreed total and force TaxPercent = 0 so the invoice
// does not apply tax a second time. Without this, imported jobs double-tax because
// their FinalPrice already includes the tax that was applied in the source environment.
if (!dto.InvoiceItems.Any())
if (!hadJobItems)
{
var defaultRevAccId = defaultRevenueAccount?.Id;
dto.InvoiceItems.Add(new CreateInvoiceItemDto
@@ -431,6 +458,26 @@ public class InvoicesController : Controller
dto.TaxPercent = sourceQuote.TaxPercent;
dto.DiscountAmount = sourceQuote.DiscountAmount;
}
else if (hadJobItems && costs?.ShopSuppliesRate > 0)
{
// Direct job — no source quote. Derive shop supplies from the items subtotal
// using the current company rate. (Quote-sourced jobs read the pre-agreed amount
// from the quote snapshot instead; this path only fires when there is no quote.)
var itemsSubtotal = dto.InvoiceItems.Sum(i => i.TotalPrice);
var shopSuppliesAmount = Math.Round(itemsSubtotal * (costs.ShopSuppliesRate / 100m), 2);
if (shopSuppliesAmount > 0.01m)
{
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
Description = $"Shop Supplies ({costs.ShopSuppliesRate:0.##}%)",
Quantity = 1,
UnitPrice = shopSuppliesAmount,
TotalPrice = shopSuppliesAmount,
DisplayOrder = order,
RevenueAccountId = defaultRevenueAccount?.Id
});
}
}
// Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
if (job.Customer?.IsTaxExempt == true)
@@ -444,7 +491,7 @@ public class InvoicesController : Controller
: string.Empty;
}
await PopulateCreateViewBagAsync(currentUser.CompanyId);
await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms);
ViewBag.GuidedActivation = guidedActivation;
return View(dto);
}
@@ -485,7 +532,7 @@ public class InvoicesController : Controller
if (!ModelState.IsValid)
{
await PopulateCreateViewBagAsync(currentUser.CompanyId);
await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms);
ViewBag.GuidedActivation = guidedActivation;
return View(dto);
}
@@ -493,21 +540,32 @@ public class InvoicesController : Controller
if (!dto.InvoiceItems.Any())
{
ModelState.AddModelError("", "Please add at least one line item before saving.");
await PopulateCreateViewBagAsync(currentUser.CompanyId);
await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms);
ViewBag.GuidedActivation = guidedActivation;
return View(dto);
}
// Validate no existing invoice for this job before starting the transaction
// Validate no existing active invoice for this job before starting the transaction.
// Voided invoices are treated as history — clear their JobId FK so the unique index
// slot is freed and the new invoice can be saved.
if (dto.JobId.HasValue)
{
var existing = await _unitOfWork.Invoices.GetForJobAsync(dto.JobId.Value, includeDeleted: true);
var existing = await _unitOfWork.Invoices.GetForJobAsync(dto.JobId.Value);
if (existing != null)
{
ModelState.AddModelError("", "An invoice already exists for this job.");
await PopulateCreateViewBagAsync(currentUser.CompanyId);
ViewBag.GuidedActivation = guidedActivation;
return View(dto);
if (existing.Status != InvoiceStatus.Voided)
{
ModelState.AddModelError("", "An invoice already exists for this job.");
await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms);
ViewBag.GuidedActivation = guidedActivation;
return View(dto);
}
// Clear the voided invoice's JobId so the unique (CompanyId, JobId) index
// allows the new invoice to be inserted.
existing.JobId = null;
await _unitOfWork.Invoices.UpdateAsync(existing);
await _unitOfWork.SaveChangesAsync();
}
}
@@ -684,7 +742,7 @@ public class InvoicesController : Controller
_logger.LogError(ex, "Error creating invoice");
TempData["Error"] = "An error occurred while creating the invoice.";
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser != null) await PopulateCreateViewBagAsync(currentUser.CompanyId);
if (currentUser != null) await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms);
ViewBag.GuidedActivation = guidedActivation;
return View(dto);
}
@@ -694,9 +752,9 @@ public class InvoicesController : Controller
// GET: /Invoices/Edit/5
// -----------------------------------------------------------------------
/// <summary>
/// Loads the Edit form. Only Draft invoices are editable — any other status redirects to
/// Details with an error. Sent/Paid/Voided invoices must be voided and recreated rather
/// than edited, to preserve the audit trail for those states.
/// Loads the Edit form. Draft, Sent, and Overdue invoices are editable. Paid, PartiallyPaid,
/// Voided, and WrittenOff invoices are locked — those statuses represent committed financial
/// records that should not be altered after the fact.
/// </summary>
public async Task<IActionResult> Edit(int? id)
{
@@ -707,9 +765,9 @@ public class InvoicesController : Controller
var invoice = await LoadInvoiceForViewAsync(id.Value);
if (invoice == null) return NotFound();
if (invoice.Status != InvoiceStatus.Draft)
if (invoice.Status is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue))
{
TempData["Error"] = "Only Draft invoices can be edited.";
TempData["Error"] = "Only open invoices (Draft, Sent, Overdue) can be edited.";
return RedirectToAction(nameof(Details), new { id });
}
@@ -748,6 +806,11 @@ public class InvoicesController : Controller
? invoice.Customer.CompanyName
: $"{invoice.Customer.ContactFirstName} {invoice.Customer.ContactLastName}".Trim())
: string.Empty;
ViewBag.InvoiceStatus = invoice.Status;
var customerEmail = invoice.Customer?.BillingEmail ?? invoice.Customer?.Email;
ViewBag.CanResend = invoice.Status is (InvoiceStatus.Sent or InvoiceStatus.Overdue)
&& !string.IsNullOrWhiteSpace(customerEmail);
ViewBag.PaymentTermsOptions = BuildPaymentTermsSelectList(dto.Terms);
return View(dto);
}
@@ -763,23 +826,23 @@ public class InvoicesController : Controller
// POST: /Invoices/Edit/5
// -----------------------------------------------------------------------
/// <summary>
/// Saves edits to a Draft invoice. Line items are replaced via a soft-delete-and-add cycle
/// (old items flagged IsDeleted, new items inserted) so the audit trail of what was originally
/// on the invoice is preserved in the database. Customer.CurrentBalance is adjusted by the
/// delta (newTotal oldTotal) so outstanding AR stays accurate without recalculating from scratch.
/// Only Draft invoices can be edited; guard is checked on both GET and POST.
/// Saves edits to an open invoice (Draft, Sent, or Overdue). Line items are replaced via a
/// soft-delete-and-add cycle so the original items are preserved in the audit trail.
/// Customer.CurrentBalance is adjusted by the delta (newTotal oldTotal). Status is kept
/// as-is (Sent stays Sent) so the customer-facing record remains consistent. If resendToCustomer
/// is true and the invoice is Sent/Overdue, a fresh PDF is emailed to the customer.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateInvoiceDto dto)
public async Task<IActionResult> Edit(int id, UpdateInvoiceDto dto, bool resendToCustomer = false)
{
try
{
var invoice = await LoadInvoiceForViewAsync(id);
if (invoice == null) return NotFound();
if (invoice.Status != InvoiceStatus.Draft)
if (invoice.Status is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue))
{
TempData["Error"] = "Only Draft invoices can be edited.";
TempData["Error"] = "Only open invoices (Draft, Sent, Overdue) can be edited.";
return RedirectToAction(nameof(Details), new { id });
}
@@ -789,6 +852,7 @@ public class InvoicesController : Controller
ViewBag.InvoiceId = invoice.Id;
ViewBag.JobNumber = invoice.Job?.JobNumber;
ViewBag.CustomerName = invoice.Customer?.CompanyName;
ViewBag.PaymentTermsOptions = BuildPaymentTermsSelectList(dto.Terms);
return View(dto);
}
@@ -862,6 +926,28 @@ public class InvoicesController : Controller
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Invoice updated successfully.";
// Optionally re-send the updated invoice PDF to the customer
if (resendToCustomer && invoice.Status is (InvoiceStatus.Sent or InvoiceStatus.Overdue))
{
try
{
var currentUserForPdf = await _userManager.GetUserAsync(User);
var pdfBytes = await BuildInvoicePdfAsync(invoice, invoice.CompanyId);
string? paymentUrl = null;
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl);
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
this.SetNotificationResultToast(notifLog);
}
catch (Exception notifyEx)
{
_logger.LogWarning(notifyEx, "Re-send of updated invoice {Id} failed", id);
TempData["WarningPermanent"] = "Invoice saved, but re-sending the email failed. You can re-send manually from the invoice details.";
}
}
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
@@ -883,7 +969,7 @@ public class InvoicesController : Controller
/// works identically in dev (localhost) and production without config changes.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Send(int id)
public async Task<IActionResult> Send(int id, string? overrideEmail = null)
{
try
{
@@ -916,7 +1002,7 @@ public class InvoicesController : Controller
try
{
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl);
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, overrideEmail: overrideEmail?.Trim());
pdfAndNotifSucceeded = true;
}
catch (Exception notifyEx)
@@ -1296,7 +1382,7 @@ public class InvoicesController : Controller
/// <see cref="BuildInvoicePdfAsync"/> which fetches company branding, template settings,
/// and the full invoice DTO in one call, then hands off to IPdfService.
/// </summary>
public async Task<IActionResult> DownloadPdf(int? id)
public async Task<IActionResult> DownloadPdf(int? id, bool inline = false)
{
if (id == null) return NotFound();
@@ -1309,7 +1395,17 @@ public class InvoicesController : Controller
if (currentUser == null) return Unauthorized();
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser.CompanyId);
return File(pdfBytes, "application/pdf", $"Invoice-{invoice.InvoiceNumber}.pdf");
var fileName = $"Invoice-{invoice.InvoiceNumber}.pdf";
if (inline)
{
// Return with inline content-disposition so the browser renders the PDF
// in a new tab, enabling the native print dialog.
Response.Headers["Content-Disposition"] = $"inline; filename=\"{fileName}\"";
return File(pdfBytes, "application/pdf");
}
return File(pdfBytes, "application/pdf", fileName);
}
catch (Exception ex)
{
@@ -1336,9 +1432,10 @@ public class InvoicesController : Controller
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId, includeDeleted: true);
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId);
if (existing != null)
// Voided invoices are kept as history — don't block creation of a new one
if (existing != null && existing.Status != InvoiceStatus.Voided)
return RedirectToAction(nameof(Details), new { id = existing.Id });
return RedirectToAction(nameof(Create), new { jobId });
@@ -1361,7 +1458,7 @@ public class InvoicesController : Controller
/// Details view can show an inline toast with the delivery outcome.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ResendInvoice(int id)
public async Task<IActionResult> ResendInvoice(int id, string? overrideEmail = null)
{
try
{
@@ -1375,11 +1472,21 @@ public class InvoicesController : Controller
if (invoice.Status == InvoiceStatus.Voided || invoice.Status == InvoiceStatus.WrittenOff)
return Json(new { success = false, message = "Voided invoices cannot be resent." });
// Validate override email when provided
overrideEmail = overrideEmail?.Trim();
if (!string.IsNullOrWhiteSpace(overrideEmail) && !overrideEmail.Contains('@'))
return Json(new { success = false, message = "The email address provided is not valid." });
var currentUser = await _userManager.GetUserAsync(User);
var recipientName = invoice.Customer?.IsCommercial == true
? invoice.Customer.CompanyName ?? "Customer"
: $"{invoice.Customer?.ContactFirstName} {invoice.Customer?.ContactLastName}".Trim();
var recipientEmail = invoice.Customer?.Email ?? string.Empty;
var recipientEmail = !string.IsNullOrWhiteSpace(overrideEmail)
? overrideEmail
: invoice.Customer?.BillingEmail ?? invoice.Customer?.Email ?? string.Empty;
if (string.IsNullOrWhiteSpace(recipientEmail))
return Json(new { success = false, message = "No email address on file. Please provide an address to send to." });
byte[]? pdfBytes = null;
string? pdfFilename = null;
@@ -1393,7 +1500,7 @@ public class InvoicesController : Controller
_logger.LogWarning(pdfEx, "PDF generation failed during resend of invoice {Id}; sending without attachment", id);
}
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, pdfFilename);
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, pdfFilename, overrideEmail: overrideEmail);
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
@@ -1403,7 +1510,7 @@ public class InvoicesController : Controller
if (latestLog?.Status == NotificationStatus.Skipped)
return Json(new { success = false, message = $"{recipientName} has email notifications disabled or no email address on file." });
return Json(new { success = true, message = $"Invoice resent to {recipientName} ({recipientEmail})." });
return Json(new { success = true, message = $"Invoice sent to {recipientEmail}." });
}
catch (Exception ex)
{
@@ -1500,6 +1607,10 @@ public class InvoicesController : Controller
if (invoice.TaxAmount > 0)
await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
// Clear the JobId FK before soft-deleting so the unique index slot is freed
// and a new invoice can be created for the same job if needed.
invoice.JobId = null;
await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.Invoices.SoftDeleteAsync(id);
}); // end ExecuteInTransactionAsync
@@ -1754,7 +1865,7 @@ public class InvoicesController : Controller
/// — Company default tax rate and set of tax-exempt customer IDs for client-side JS to auto-zero tax.
/// — Merchandise catalog items serialized as camelCase JSON for the invoice line-item picker modal.
/// </summary>
private async Task PopulateCreateViewBagAsync(int companyId)
private async Task PopulateCreateViewBagAsync(int companyId, string? selectedTerms = null)
{
var customers = await _unitOfWork.Customers.GetAllAsync();
ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList();
@@ -1768,6 +1879,12 @@ public class InvoicesController : Controller
.Select(c => c.Id)
.ToHashSet();
// Payment terms dropdown — pre-select selectedTerms if provided, else company default
var prefs = await _unitOfWork.CompanyPreferences
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
var defaultTerms = selectedTerms ?? prefs?.DefaultPaymentTerms ?? "Net 30";
ViewBag.PaymentTermsOptions = BuildPaymentTermsSelectList(defaultTerms);
// Merchandise items for the invoice merch picker (all active IsMerchandise items)
var allMerchItems = await _unitOfWork.CatalogItems.FindAsync(
i => i.IsMerchandise && i.IsActive, false, i => i.Category);
@@ -1787,7 +1904,9 @@ public class InvoicesController : Controller
private async Task PopulateBankAccountsAsync()
{
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive
&& (a.AccountSubType == AccountSubType.Checking || a.AccountSubType == AccountSubType.Savings));
&& (a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings));
ViewBag.BankAccounts = accounts
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
@@ -395,11 +395,17 @@ public class JobsController : Controller
ViewBag.UseMetric = useMetric;
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
// Check if an invoice exists for this job
var jobInvoice = await _unitOfWork.Invoices.GetForJobAsync(id.Value);
// Separate active invoice from voided history for this job
var allJobInvoices = await _unitOfWork.Invoices.FindAsync(i => i.JobId == id.Value);
var jobInvoice = allJobInvoices.FirstOrDefault(i => i.Status != Core.Enums.InvoiceStatus.Voided);
var voidedInvoices = allJobInvoices
.Where(i => i.Status == Core.Enums.InvoiceStatus.Voided)
.Select(i => new { i.Id, i.InvoiceNumber })
.ToList<dynamic>();
ViewBag.JobInvoiceId = jobInvoice?.Id;
ViewBag.JobInvoiceNumber = jobInvoice?.InvoiceNumber;
ViewBag.JobInvoiceStatus = jobInvoice?.Status;
ViewBag.JobVoidedInvoices = voidedInvoices;
// Workers dropdown for inline assignment
await PopulateWorkersDropdown();
@@ -410,11 +416,79 @@ public class JobsController : Controller
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
.ToListAsync();
ViewBag.ShopWorkers = companyUsers.Select(u => new { Id = u.Id, Name = u.FullName }).ToList();
ViewBag.CurrentUserId = _userManager.GetUserId(User);
// Populate Edit Items wizard data (inline modal on Details page)
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m;
// Internal pricing breakdown (not printed — mirrors quote details breakdown)
var breakdownItems = job.JobItems
.Where(ji => !ji.IsDeleted)
.Select(ji => new CreateQuoteItemDto
{
Description = ji.Description,
Quantity = ji.Quantity,
SurfaceAreaSqFt = ji.SurfaceAreaSqFt,
EstimatedMinutes = ji.EstimatedMinutes,
CatalogItemId = ji.CatalogItemId,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
IsLaborItem = ji.IsLaborItem,
IsSalesItem = ji.IsSalesItem,
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
PowderCostOverride = ji.PowderCostOverride,
IncludePrepCost = ji.IncludePrepCost,
Complexity = ji.Complexity,
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
{
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder
}).ToList(),
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
{
PrepServiceId = ps.PrepServiceId,
EstimatedMinutes = ps.EstimatedMinutes
}).ToList()
}).ToList();
if (breakdownItems.Any())
{
var pr = await _pricingService.CalculateQuoteTotalsAsync(
breakdownItems, job.CompanyId, job.CustomerId,
wizardCosts?.TaxPercent ?? 0m,
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
job.OvenCostId, 1, null);
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
{
MaterialCosts = pr.MaterialCosts,
LaborCosts = pr.LaborCosts,
EquipmentCosts = pr.EquipmentCosts,
ItemsSubtotal = pr.ItemsSubtotal,
OvenBatchCost = pr.OvenBatchCost,
OvenBatches = pr.OvenBatches,
OvenCycleMinutes = pr.OvenCycleMinutes > 0 ? pr.OvenCycleMinutes : (wizardCosts?.DefaultOvenCycleMinutes ?? 0),
FacilityOverheadCost = pr.FacilityOverheadCost,
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
ShopSuppliesAmount = pr.ShopSuppliesAmount,
ShopSuppliesPercent = pr.ShopSuppliesPercent,
OverheadCosts = pr.OverheadCosts,
OverheadPercent = pr.OverheadPercent,
ProfitMargin = pr.ProfitMargin,
ProfitPercent = pr.ProfitPercent,
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
DiscountAmount = pr.DiscountAmount,
DiscountPercent = pr.DiscountPercent,
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
RushFee = pr.RushFee,
TaxAmount = pr.TaxAmount,
TaxPercent = pr.TaxPercent,
Total = pr.Total
};
}
ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
ViewBag.ComplexityModeratePercent = wizardCosts?.ComplexityModeratePercent ?? 5m;
ViewBag.ComplexityComplexPercent = wizardCosts?.ComplexityComplexPercent ?? 15m;
@@ -465,9 +539,15 @@ public class JobsController : Controller
ViewBag.Deposits = jobDeposits;
// Materials used on this job via QR scan or manual log
ViewBag.MaterialsUsed = (await _unitOfWork.InventoryTransactions.FindAsync(
var allJobTransactions = (await _unitOfWork.InventoryTransactions.FindAsync(
t => t.JobId == id.Value, false, t => t.InventoryItem))
.OrderByDescending(t => t.TransactionDate).ToList();
ViewBag.MaterialsUsed = allJobTransactions;
// Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
ViewBag.PreLoggedPowder = allJobTransactions
.GroupBy(t => t.InventoryItemId)
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
// Job photo subscription limits — used to disable the upload button in the view
var photoCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
@@ -1153,7 +1233,9 @@ public class JobsController : Controller
createCosts?.TaxPercent ?? 0m,
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null);
job.FinalPrice = totals.Total;
job.FinalPrice = totals.Total;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Jobs.UpdateAsync(job);
await _unitOfWork.SaveChangesAsync();
@@ -1667,7 +1749,9 @@ public class JobsController : Controller
dto.JobItems, companyId, dto.CustomerId,
editCosts?.TaxPercent ?? 0m,
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null);
job.FinalPrice = totals.Total;
job.FinalPrice = totals.Total;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
}
// Save change history records
@@ -2702,6 +2786,15 @@ public class JobsController : Controller
job.JobStatusId = completedStatus.Id;
}
// Build a mutable credit map: lbs already deducted from inventory for this job
// (via QR scan / LogUsage before completion). We consume this credit per InventoryItemId
// so we only deduct the net delta and never double-subtract.
var preLoggedTransactions = await _unitOfWork.InventoryTransactions.FindAsync(
t => t.JobId == dto.JobId);
var preLoggedCredit = preLoggedTransactions
.GroupBy(t => t.InventoryItemId)
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
// Update actual powder usage for each coat
foreach (var coatUsage in dto.CoatUsages)
{
@@ -2723,37 +2816,49 @@ public class JobsController : Controller
coatUsage.ActualPowderUsedLbs.HasValue &&
coatUsage.ActualPowderUsedLbs.Value > 0)
{
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(jobItemCoat.InventoryItemId.Value);
if (inventoryItem != null)
var invItemId = jobItemCoat.InventoryItemId.Value;
var actualLbs = coatUsage.ActualPowderUsedLbs.Value;
// Apply available pre-logged credit so we don't double-deduct
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
var deductNow = Math.Max(0m, actualLbs - credit);
// Consume credit (other coats sharing the same powder get whatever remains)
preLoggedCredit[invItemId] = Math.Max(0m, credit - actualLbs);
if (deductNow > 0)
{
// Create inventory transaction to track the usage
var transaction = new InventoryTransaction
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
if (inventoryItem != null)
{
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -coatUsage.ActualPowderUsedLbs.Value, // Negative for deduction
UnitCost = inventoryItem.UnitCost,
TotalCost = inventoryItem.UnitCost * coatUsage.ActualPowderUsedLbs.Value,
TransactionDate = DateTime.UtcNow,
JobId = job.Id,
Reference = job.JobNumber,
Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}",
BalanceAfter = inventoryItem.QuantityOnHand - coatUsage.ActualPowderUsedLbs.Value,
CompanyId = job.CompanyId
};
var transaction = new InventoryTransaction
{
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -deductNow,
UnitCost = inventoryItem.UnitCost,
TotalCost = inventoryItem.UnitCost * deductNow,
TransactionDate = DateTime.UtcNow,
JobId = job.Id,
Reference = job.JobNumber,
Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}",
BalanceAfter = inventoryItem.QuantityOnHand - deductNow,
CompanyId = job.CompanyId
};
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
// Update inventory item quantity
inventoryItem.QuantityOnHand -= coatUsage.ActualPowderUsedLbs.Value;
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
inventoryItem.QuantityOnHand -= deductNow;
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
_logger.LogInformation(
"Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
}
}
else
{
_logger.LogInformation(
"Deducted {Lbs} lbs of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
coatUsage.ActualPowderUsedLbs.Value,
inventoryItem.Name,
job.JobNumber,
inventoryItem.QuantityOnHand);
"Skipped inventory deduction for JobItemCoat {CoatId} — {Lbs} lbs already pre-logged for inventory item {InvItemId}",
coatUsage.JobItemCoatId, actualLbs, invItemId);
}
}
}
@@ -3113,7 +3218,9 @@ public class JobsController : Controller
model.JobItems, currentUser.CompanyId, job.CustomerId,
model.TaxPercent, "None", 0, false, null, 1, null);
job.FinalPrice = totals.Total;
job.FinalPrice = totals.Total;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.UpdatedAt = DateTime.UtcNow;
job.UpdatedBy = currentUser.UserName;
await _unitOfWork.Jobs.UpdateAsync(job);
@@ -3184,11 +3291,15 @@ public class JobsController : Controller
var totals = await _pricingService.CalculateQuoteTotalsAsync(
remainingDtos, currentUser.CompanyId, job.CustomerId,
costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null);
job.FinalPrice = totals.Total;
job.FinalPrice = totals.Total;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
}
else
{
job.FinalPrice = 0;
job.FinalPrice = 0;
job.ShopSuppliesAmount = 0;
job.ShopSuppliesPercent = 0;
}
job.UpdatedAt = DateTime.UtcNow;
@@ -3211,18 +3322,21 @@ public class JobsController : Controller
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();
var vendors = await _unitOfWork.Vendors.GetAllAsync(false);
@@ -3896,9 +4010,11 @@ public class JobsController : Controller
}
// Update pricing from quote and advance the snapshot so banner clears
job.QuotedPrice = quote.Total;
job.FinalPrice = quote.Total;
job.QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt;
job.QuotedPrice = quote.Total;
job.FinalPrice = quote.Total;
job.ShopSuppliesAmount = quote.ShopSuppliesAmount;
job.ShopSuppliesPercent = quote.ShopSuppliesPercent;
job.QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt;
await _unitOfWork.CompleteAsync();
});
@@ -3933,7 +4049,8 @@ public class JobsController : Controller
// Operating costs for fallback labor rate and oven rate
var opCosts = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
var fallbackLaborRate = opCosts?.StandardLaborRate ?? 0m;
var defaultOvenCycleHours = (opCosts?.DefaultOvenCycleMinutes ?? 45) / 60.0m;
var effectiveOvenMinutes = (opCosts?.DefaultOvenCycleMinutes > 0 ? (int?)opCosts!.DefaultOvenCycleMinutes : null) ?? 45;
var defaultOvenCycleHours = effectiveOvenMinutes / 60.0m;
// Role cost rates map: role → hourly rate
var roleCosts = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId);
@@ -3942,6 +4059,7 @@ public class JobsController : Controller
// 1. Powder / Material cost
decimal powderCost = 0m;
var powderLines = new List<object>();
bool hasCoatsWithRateButNoQty = false;
foreach (var item in job.JobItems)
{
foreach (var coat in item.Coats)
@@ -3960,6 +4078,11 @@ public class JobsController : Controller
isActual = coat.ActualPowderUsedLbs.HasValue
});
}
else if (costPerLb > 0 && lbs == 0)
{
// Coat has a price/lb but no quantity — surface area missing on the item
hasCoatsWithRateButNoQty = true;
}
}
}
@@ -4040,7 +4163,7 @@ public class JobsController : Controller
laborCost = Math.Round(laborCost, 2),
ovenCost = Math.Round(ovenCost, 2),
ovenLabel,
ovenCycleMinutes = opCosts?.DefaultOvenCycleMinutes ?? 45,
ovenCycleMinutes = effectiveOvenMinutes,
reworkCostTotal = Math.Round(reworkCostTotal, 2),
reworkBilledToCustomer = Math.Round(reworkBilledToCustomer, 2),
netReworkCost = Math.Round(netReworkCost, 2),
@@ -4055,6 +4178,7 @@ public class JobsController : Controller
powderLines,
laborLines,
hasPowderData = powderLines.Count > 0,
hasPowderRateButNoQty = hasCoatsWithRateButNoQty && powderLines.Count == 0,
hasLaborData = laborLines.Count > 0
});
}
@@ -454,6 +454,10 @@ public class QuoteApprovalController : Controller
CustomerEmail = quote.Customer?.Email ?? quote.ProspectEmail,
ExpirationDate = quote.ExpirationDate,
ApprovalTokenExpiresAt = quote.ApprovalTokenExpiresAt,
ItemsSubtotal = quote.ItemsSubtotal,
OvenBatchCost = quote.OvenBatchCost,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
SubTotal = quote.SubTotal,
DiscountAmount = quote.DiscountAmount,
HideDiscountFromCustomer = quote.HideDiscountFromCustomer,
@@ -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(), " ");
}
@@ -12,6 +12,10 @@ public class QuoteApprovalViewModel
public string? CustomerEmail { get; set; }
public DateTime? ExpirationDate { get; set; }
public DateTime? ApprovalTokenExpiresAt { get; set; }
public decimal ItemsSubtotal { get; set; }
public decimal OvenBatchCost { get; set; }
public decimal ShopSuppliesAmount { get; set; }
public decimal ShopSuppliesPercent { get; set; }
public decimal SubTotal { get; set; }
public decimal DiscountAmount { get; set; }
public bool HideDiscountFromCustomer { get; set; }
@@ -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">
@@ -74,7 +74,7 @@
</div>
<div class="col-md-6">
<label asp-for="Email" class="form-label">Email <span class="text-danger">*</span> <span class="text-muted fw-normal">(required if no phone number)</span></label>
<input asp-for="Email" type="email" class="form-control" placeholder="name@example.com" />
<input asp-for="Email" type="email" multiple class="form-control" placeholder="name@example.com (comma-separate multiple)" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="col-md-3">
@@ -87,6 +87,14 @@
<input asp-for="MobilePhone" type="tel" class="form-control" placeholder="(555) 123-4567" />
<span asp-validation-for="MobilePhone" class="text-danger"></span>
</div>
<div class="col-md-6" id="billingEmailRow" style="display:none;">
<label asp-for="BillingEmail" class="form-label">Billing / Accounting Email
<span class="text-muted fw-normal">(invoices sent here)</span>
</label>
<input asp-for="BillingEmail" type="email" multiple class="form-control" placeholder="accounting@company.com (comma-separate multiple)" />
<span asp-validation-for="BillingEmail" class="text-danger"></span>
<div class="form-text">When set, invoices are emailed here instead of the contact email.</div>
</div>
</div>
</div>
@@ -372,4 +380,5 @@
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script src="~/js/customer-billing-email.js"></script>
}
@@ -97,11 +97,37 @@
<div class="col-md-6">
<label class="text-muted small mb-1">Email</label>
<p class="mb-0">
<a href="mailto:@Model.Email" class="text-decoration-none">
<i class="bi bi-envelope me-1"></i>@Model.Email
</a>
@if (!string.IsNullOrEmpty(Model.Email))
{
<a href="mailto:@Model.Email" class="text-decoration-none">
<i class="bi bi-envelope me-1"></i>@Model.Email
</a>
}
else
{
<span class="text-muted">Not provided</span>
}
</p>
</div>
@if (Model.IsCommercial)
{
<div class="col-md-6">
<label class="text-muted small mb-1">Billing / Accounting Email</label>
<p class="mb-0">
@if (!string.IsNullOrEmpty(Model.BillingEmail))
{
<a href="mailto:@Model.BillingEmail" class="text-decoration-none">
<i class="bi bi-envelope-at me-1"></i>@Model.BillingEmail
</a>
<span class="badge bg-info bg-opacity-10 text-info ms-2 small">Invoices</span>
}
else
{
<span class="text-muted">Not set — invoices go to contact email</span>
}
</p>
</div>
}
<div class="col-md-6">
<label class="text-muted small mb-1">Phone</label>
<p class="mb-0">
@@ -78,7 +78,7 @@
</div>
<div class="col-md-6">
<label asp-for="Email" class="form-label">Email</label>
<input asp-for="Email" type="email" class="form-control" placeholder="name@example.com" />
<input asp-for="Email" type="email" multiple class="form-control" placeholder="name@example.com (comma-separate multiple)" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="col-md-3">
@@ -91,6 +91,14 @@
<input asp-for="MobilePhone" type="tel" class="form-control" placeholder="(555) 123-4567" />
<span asp-validation-for="MobilePhone" class="text-danger"></span>
</div>
<div class="col-md-6" id="billingEmailRow" style="display:none;">
<label asp-for="BillingEmail" class="form-label">Billing / Accounting Email
<span class="text-muted fw-normal">(invoices sent here)</span>
</label>
<input asp-for="BillingEmail" type="email" multiple class="form-control" placeholder="accounting@company.com (comma-separate multiple)" />
<span asp-validation-for="BillingEmail" class="text-danger"></span>
<div class="form-text">When set, invoices are emailed here instead of the contact email.</div>
</div>
</div>
</div>
@@ -436,4 +444,5 @@
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script src="~/js/customer-billing-email.js"></script>
}
@@ -386,7 +386,7 @@
</div>
</div>
<!-- Notes -->
<!-- Notes & Status -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-journal-text me-2 text-primary"></i>Notes
@@ -397,6 +397,17 @@
<textarea asp-for="Notes" class="form-control" rows="3"></textarea>
<span asp-validation-for="Notes" class="text-danger"></span>
</div>
<div class="col-12">
<div class="form-check">
<input asp-for="IsIncoming" class="form-check-input" id="IsIncoming" />
<label class="form-check-label fw-semibold" for="IsIncoming">
<i class="bi bi-truck me-1 text-warning"></i>Incoming / On Order
</label>
</div>
<small class="text-muted d-block mt-1">
Check this when the powder has been ordered but not yet received. It will appear with an "Incoming" badge in the inventory list and can be selected on quotes so staff can print QR codes while the powder is in transit. Pricing will charge for the full ordered quantity.
</small>
</div>
</div>
</div>
@@ -398,6 +398,17 @@
<textarea asp-for="Notes" class="form-control" rows="3"></textarea>
<span asp-validation-for="Notes" class="text-danger"></span>
</div>
<div class="col-12">
<div class="form-check">
<input asp-for="IsIncoming" class="form-check-input" id="IsIncoming" />
<label class="form-check-label fw-semibold" for="IsIncoming">
<i class="bi bi-truck me-1 text-warning"></i>Incoming / On Order
</label>
</div>
<small class="text-muted d-block mt-1">
Uncheck once the powder has been received to mark it as regular in-stock inventory.
</small>
</div>
</div>
</div>
@@ -275,7 +275,13 @@
<span class="fw-semibold">@((item.QuantityOnHand * item.UnitCost).ToString("C"))</span>
</td>
<td>
@if (item.IsActive)
@if (item.IsIncoming)
{
<span class="badge bg-warning bg-opacity-25 text-warning-emphasis">
<i class="bi bi-truck me-1"></i>Incoming
</span>
}
else if (item.IsActive)
{
<span class="badge bg-success bg-opacity-10 text-success">
<i class="bi bi-check-circle me-1"></i>Active
@@ -175,11 +175,11 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Payment Terms"
data-bs-content="Free-text field that prints on the invoice (e.g., 'Net 30', 'Due on Receipt', '2% 10 Net 30'). Pre-filled from the customer's default payment terms. Changing it here only affects this invoice.">
data-bs-content="Prints on the invoice. Pre-filled from your App Defaults. Changing it here only affects this invoice.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="Terms" class="form-control" placeholder="e.g. Net 30" />
<select asp-for="Terms" asp-items="ViewBag.PaymentTermsOptions" class="form-select"></select>
</div>
</div>
</div>
@@ -446,6 +446,7 @@
</div>
@section Scripts {
<script src="~/js/invoice-due-date.js"></script>
<script>
let itemCount = @Model.InvoiceItems.Count;
const merchandiseItems = @Html.Raw(ViewBag.MerchandiseItems ?? "[]");
@@ -10,6 +10,7 @@
var statusDisplay = InvoicesController.GetStatusDisplay(Model.Status);
var isDraft = Model.Status == InvoiceStatus.Draft;
var isVoided = Model.Status == InvoiceStatus.Voided || Model.Status == InvoiceStatus.WrittenOff;
var canEdit = Model.Status is InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue;
var canPay = !isVoided && Model.BalanceDue > 0;
var canResend = !isDraft && !isVoided && Model.Status != InvoiceStatus.Paid;
var hasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail);
@@ -30,12 +31,16 @@
<!-- Header -->
<div class="d-flex justify-content-end gap-2 mb-4">
@if (isDraft)
@if (canEdit)
{
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
<i class="bi bi-pencil me-2"></i>Edit
</a>
}
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" asp-route-inline="true"
class="btn btn-outline-secondary" target="_blank" rel="noopener">
<i class="bi bi-printer me-2"></i>Print
</a>
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-file-pdf me-2"></i>PDF
</a>
@@ -64,7 +69,7 @@
<div class="alert alert-warning d-flex align-items-center gap-2 mb-4">
<i class="bi bi-envelope-slash fs-5"></i>
<span>
<strong>@Model.CustomerName</strong> has no email address on file — email buttons are hidden.
<strong>@Model.CustomerName</strong> has no email address on file — you'll be prompted to enter one when sending.
<a asp-controller="Customers" asp-action="Edit" asp-route-id="@Model.CustomerId" class="alert-link">Add one in customer settings</a>.
</span>
</div>
@@ -566,31 +571,37 @@
</div>
<div class="card-body">
<div class="d-grid gap-2">
@if (isDraft)
@if (canEdit)
{
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-primary">
<i class="bi bi-pencil me-2"></i>Edit Invoice
</a>
@if (hasEmail)
{
<form id="sendInvoiceForm" asp-action="Send" asp-route-id="@Model.Id" method="post">
@Html.AntiForgeryToken()
@if (emailOptedOut)
{
<button type="button" class="btn btn-primary w-100" disabled
title="Email notifications are turned off for this customer">
<i class="bi bi-send me-2"></i>Send Invoice
</button>
}
else
{
<button type="button" class="btn btn-primary w-100"
data-bs-toggle="modal" data-bs-target="#sendInvoiceModal">
<i class="bi bi-send me-2"></i>Send Invoice
</button>
}
</form>
}
<form id="sendInvoiceForm" asp-action="Send" asp-route-id="@Model.Id" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="overrideEmail" id="sendInvoiceOverrideEmail" value="" />
@if (emailOptedOut)
{
<button type="button" class="btn btn-primary w-100" disabled
title="Email notifications are turned off for this customer">
<i class="bi bi-send me-2"></i>Send Invoice
</button>
}
else if (hasEmail)
{
<button type="button" class="btn btn-primary w-100"
data-bs-toggle="modal" data-bs-target="#sendInvoiceModal">
<i class="bi bi-send me-2"></i>Send Invoice
</button>
}
else
{
<button type="button" class="btn btn-primary w-100"
data-bs-toggle="modal" data-bs-target="#sendToAdHocEmailModal"
onclick="document.getElementById('adHocEmailMode').value='send'">
<i class="bi bi-send me-2"></i>Send Invoice
</button>
}
</form>
}
@if (canPay)
{
@@ -598,12 +609,23 @@
<i class="bi bi-cash me-2"></i>Record Payment
</button>
}
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" asp-route-inline="true"
class="btn btn-outline-secondary" target="_blank" rel="noopener">
<i class="bi bi-printer me-2"></i>Print
</a>
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-file-pdf me-2"></i>Download PDF
</a>
@if (canResend && hasEmail)
@if (canResend)
{
@if (emailOptedOut)
@if (!hasEmail)
{
<button type="button" class="btn btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#sendToAdHocEmailModal">
<i class="bi bi-send me-2"></i>Send Invoice
</button>
}
else if (emailOptedOut)
{
<button type="button" class="btn btn-outline-primary" disabled
title="Email notifications are turned off for this customer">
@@ -978,6 +1000,34 @@
</div>
}
<!-- Send to Ad-hoc Email Modal -->
<div class="modal fade" id="sendToAdHocEmailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-send me-2"></i>Send Invoice</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted mb-3">No email address is on file for this customer. Enter an address below to send the invoice.</p>
<div class="mb-3">
<label for="adHocEmailInput" class="form-label fw-medium">Send To</label>
<input type="email" id="adHocEmailInput" class="form-control" placeholder="recipient@example.com" />
<div class="form-text">This address will not be saved to the customer record.</div>
</div>
<div id="adHocEmailError" class="alert alert-danger alert-permanent d-none py-2"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<input type="hidden" id="adHocEmailMode" value="resend" />
<button type="button" class="btn btn-primary" onclick="sendToAdHocEmail(@Model.Id)">
<i class="bi bi-send me-1"></i>Send Invoice
</button>
</div>
</div>
</div>
</div>
<!-- Re-send Invoice Modal (AJAX) -->
<div class="modal fade" id="resendInvoiceModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
@@ -1299,7 +1349,27 @@
}, 400);
}
function resendInvoice(invoiceId) {
function sendToAdHocEmail(invoiceId) {
const email = (document.getElementById('adHocEmailInput').value ?? '').trim();
const errDiv = document.getElementById('adHocEmailError');
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('sendToAdHocEmailModal'))?.hide();
const mode = document.getElementById('adHocEmailMode')?.value ?? 'resend';
if (mode === 'send') {
document.getElementById('sendInvoiceOverrideEmail').value = email;
document.getElementById('sendInvoiceForm').submit();
} else {
resendInvoice(invoiceId, email);
}
}
function resendInvoice(invoiceId, overrideEmail) {
document.getElementById('resendInvoiceSending').classList.remove('d-none');
document.getElementById('resendInvoiceResult').classList.add('d-none');
document.getElementById('resendInvoiceFooter').classList.add('d-none');
@@ -1309,8 +1379,10 @@
modal.show();
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
let url = '@Url.Action("ResendInvoice", "Invoices")?id=' + invoiceId;
if (overrideEmail) url += '&overrideEmail=' + encodeURIComponent(overrideEmail);
fetch('@Url.Action("ResendInvoice", "Invoices")?id=' + invoiceId, {
fetch(url, {
method: 'POST',
headers: { 'RequestVerificationToken': token, 'X-Requested-With': 'XMLHttpRequest' }
})
@@ -7,6 +7,7 @@
var invoiceId = (int)(ViewBag.InvoiceId ?? 0);
var jobNumber = ViewBag.JobNumber as string;
var customerName = ViewBag.CustomerName as string;
var canResend = ViewBag.CanResend == true;
}
<div class="d-flex justify-content-between align-items-center mb-4">
@@ -37,7 +38,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Invoice Details"
data-bs-content="Invoice Date is the date of issue and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice — changing it here only affects this invoice. Only Draft invoices can be edited; sending locks the invoice.">
data-bs-content="Invoice Date is the date of issue and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice — changing it here only affects this invoice. Draft, Sent, and Overdue invoices can be edited; Paid and Partially Paid invoices are locked.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -64,7 +65,7 @@
<div class="row g-3 mt-1">
<div class="col-md-12">
<label asp-for="Terms" class="form-label fw-semibold">Payment Terms</label>
<input asp-for="Terms" class="form-control" placeholder="e.g. Net 30" />
<select asp-for="Terms" asp-items="ViewBag.PaymentTermsOptions" class="form-select"></select>
</div>
</div>
</div>
@@ -234,6 +235,15 @@
<!-- Actions -->
<div class="card border-0 shadow-sm">
<div class="card-body d-grid gap-2">
@if (canResend)
{
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" name="resendToCustomer" value="true" id="resendCheck" />
<label class="form-check-label small" for="resendCheck">
<i class="bi bi-send me-1"></i>Re-send updated invoice to customer
</label>
</div>
}
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-2"></i>Save Changes
</button>
@@ -242,9 +252,10 @@
</a>
</div>
<div class="card-footer border-0 pt-0">
<div class="alert alert-warning mb-0 small py-2">
<i class="bi bi-exclamation-triangle me-1"></i>
Only <strong>Draft</strong> invoices can be edited. Send the invoice to lock it.
<div class="alert alert-info mb-0 small py-2">
<i class="bi bi-info-circle me-1"></i>
<strong>Draft, Sent,</strong> and <strong>Overdue</strong> invoices can be edited.
Paid invoices are locked.
</div>
</div>
</div>
+266 -14
View File
@@ -1315,6 +1315,7 @@
}
@{
var panelInvoiceId = ViewBag.JobInvoiceId as int?;
var voidedInvoices = ViewBag.JobVoidedInvoices as IEnumerable<dynamic> ?? [];
}
@if (panelInvoiceId.HasValue)
{
@@ -1330,6 +1331,13 @@
<i class="bi bi-receipt me-2"></i>Create Invoice
</a>
}
@foreach (var vi in voidedInvoices)
{
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@vi.Id"
class="btn btn-outline-secondary btn-sm" title="Voided invoice">
<i class="bi bi-x-circle me-1 text-danger"></i>@vi.InvoiceNumber <span class="text-muted">(Voided)</span>
</a>
}
<a asp-action="WorkOrder" asp-route-id="@Model.Id" class="btn btn-outline-secondary" target="_blank">
<i class="bi bi-printer me-2"></i>Print Work Order
</a>
@@ -1360,17 +1368,20 @@
</div>
</div>
<!-- Pricing Summary -->
<div class="card border-0 shadow-sm mb-4">
<!-- Pricing Summary (internal — d-print-none) -->
@{
var jobPb = ViewBag.JobPricingBreakdown as PowderCoating.Application.DTOs.Quote.QuotePricingBreakdownDto;
}
<div class="card border-0 shadow-sm mb-4 d-print-none">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-currency-dollar me-2 text-primary"></i>Pricing
<i class="bi bi-cash-stack me-2 text-primary"></i>Pricing Summary
</h5>
</div>
<div class="card-body">
@if (!string.IsNullOrWhiteSpace(Model.OvenLabel))
{
<div class="mb-3 p-2 bg-body-secondary rounded d-flex align-items-center">
<div class="d-flex align-items-center mb-3 p-2 bg-body-secondary rounded">
<i class="bi bi-thermometer-half text-warning me-2"></i>
<div>
<small class="text-muted d-block">Oven</small>
@@ -1378,14 +1389,253 @@
</div>
</div>
}
<div class="mb-3">
<label class="text-muted small mb-1">Quoted Price</label>
<p class="mb-0 fw-semibold">@Model.QuotedPrice.ToString("C")</p>
</div>
<div>
<label class="text-muted small mb-1">Final Price</label>
<h3 class="mb-0 text-primary">@Model.FinalPrice.ToString("C")</h3>
</div>
@if (jobPb != null)
{
<div class="d-flex justify-content-between mb-2">
<span>Items Subtotal:</span>
<strong>@jobPb.ItemsSubtotal.ToString("C")</strong>
</div>
@if (jobPb.OvenBatchCost > 0)
{
<div class="d-flex justify-content-between mb-2">
<span><i class="bi bi-fire me-1"></i>Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" × {jobPb.OvenCycleMinutes} min" : "")):</span>
<strong>@jobPb.OvenBatchCost.ToString("C")</strong>
</div>
}
@if (jobPb.FacilityOverheadCost > 0)
{
<div class="d-flex justify-content-between mb-2">
<span><i class="bi bi-building me-1"></i>Facility Overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr):</span>
<strong>@jobPb.FacilityOverheadCost.ToString("C")</strong>
</div>
}
@if (jobPb.ShopSuppliesAmount > 0)
{
<div class="d-flex justify-content-between mb-2">
<span>Shop Supplies (@jobPb.ShopSuppliesPercent%):</span>
<strong>@jobPb.ShopSuppliesAmount.ToString("C")</strong>
</div>
}
<div class="d-flex justify-content-between mb-2">
<span>Subtotal:</span>
<strong>@jobPb.SubtotalBeforeDiscount.ToString("C")</strong>
</div>
@if (jobPb.DiscountAmount > 0)
{
<div class="d-flex justify-content-between mb-2 text-success">
<span>
@if (Model.DiscountType == "Percentage")
{
<text>Discount (@Model.DiscountValue% Off):</text>
}
else if (Model.DiscountType == "FixedAmount")
{
<text>Discount (@Model.DiscountValue.ToString("C") Off):</text>
}
else
{
<text>Discount (@jobPb.DiscountPercent.ToString("F1")%):</text>
}
</span>
<strong>-@jobPb.DiscountAmount.ToString("C")</strong>
</div>
@if (!string.IsNullOrWhiteSpace(Model.DiscountReason))
{
<div class="mb-2">
<small class="text-muted fst-italic">
<i class="bi bi-info-circle me-1"></i>Reason: @Model.DiscountReason
</small>
</div>
}
}
@if (Model.IsRushJob && jobPb.RushFee > 0)
{
<div class="d-flex justify-content-between mb-2 text-warning">
<span><i class="bi bi-lightning-fill me-1"></i>Rush Job Fee:</span>
<strong>@jobPb.RushFee.ToString("C")</strong>
</div>
}
@if (jobPb.TaxAmount > 0)
{
<div class="d-flex justify-content-between mb-2">
<span>Tax (@jobPb.TaxPercent.ToString("G29")%):</span>
<strong>@jobPb.TaxAmount.ToString("C")</strong>
</div>
}
<hr />
<div class="d-flex justify-content-between mb-3">
<h5>Total:</h5>
<h5 class="text-primary"><strong>@jobPb.Total.ToString("C")</strong></h5>
</div>
@* Collapsible detail breakdown *@
<button class="btn btn-sm btn-outline-secondary w-100" type="button" data-bs-toggle="collapse" data-bs-target="#jobPricingBreakdown">
<i class="bi bi-calculator me-1"></i>Cost Breakdown
</button>
<div class="collapse mt-3" id="jobPricingBreakdown">
@{
var directCosts = jobPb.MaterialCosts + jobPb.LaborCosts + jobPb.EquipmentCosts;
var hasCostBreakdown = directCosts > 0;
var allCatalog = Model.Items != null && Model.Items.All(i => i.CatalogItemId.HasValue);
}
@* Section 1: Item Costs *@
<div class="mb-3">
<div class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.05em;">
<i class="bi bi-boxes me-1"></i>Item Costs
</div>
@if (hasCostBreakdown)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Material (powder + consumables)</span>
<span>@jobPb.MaterialCosts.ToString("C")</span>
</div>
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Labor</span>
<span>@jobPb.LaborCosts.ToString("C")</span>
</div>
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Equipment (oven + booth)</span>
<span>@jobPb.EquipmentCosts.ToString("C")</span>
</div>
<div class="d-flex justify-content-between small border-top pt-1 mt-1">
<span class="text-muted">Direct costs</span>
<span>@directCosts.ToString("C")</span>
</div>
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Markup (@jobPb.ProfitPercent.ToString("F0")% baked into item prices)</span>
<span>@((jobPb.ItemsSubtotal - directCosts).ToString("C"))</span>
</div>
}
else if (allCatalog)
{
<div class="text-muted small fst-italic">All items use fixed catalog pricing — no per-category cost split available.</div>
}
else
{
<div class="text-muted small fst-italic">Cost breakdown not available.</div>
}
<div class="d-flex justify-content-between small fw-semibold border-top pt-1 mt-1">
<span>Items subtotal</span>
<span>@jobPb.ItemsSubtotal.ToString("C")</span>
</div>
</div>
@* Section 2: Job-Level Additions *@
@if (jobPb.OvenBatchCost > 0 || jobPb.FacilityOverheadCost > 0 || jobPb.ShopSuppliesAmount > 0 || jobPb.OverheadCosts > 0)
{
<div class="mb-3">
<div class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.05em;">
<i class="bi bi-plus-circle me-1"></i>Job-Level Additions
</div>
@if (jobPb.OvenBatchCost > 0)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Oven batch (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $", {jobPb.OvenCycleMinutes} min/cycle" : ""))</span>
<span>@jobPb.OvenBatchCost.ToString("C")</span>
</div>
}
@if (jobPb.FacilityOverheadCost > 0)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr × estimated hours)</span>
<span>@jobPb.FacilityOverheadCost.ToString("C")</span>
</div>
}
@if (jobPb.ShopSuppliesAmount > 0)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Shop supplies (@jobPb.ShopSuppliesPercent.ToString("F1")%)</span>
<span>@jobPb.ShopSuppliesAmount.ToString("C")</span>
</div>
}
@if (jobPb.OverheadCosts > 0)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Overhead (@jobPb.OverheadPercent.ToString("F1")%)</span>
<span>@jobPb.OverheadCosts.ToString("C")</span>
</div>
}
</div>
}
@* Section 3: Final Calculation *@
<div class="mb-2">
<div class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.05em;">
<i class="bi bi-receipt me-1"></i>Final Calculation
</div>
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Subtotal</span>
<span>@jobPb.SubtotalBeforeDiscount.ToString("C")</span>
</div>
@if (jobPb.DiscountAmount > 0)
{
<div class="d-flex justify-content-between small mb-1 text-success">
<span>Discount (@jobPb.DiscountPercent.ToString("F1")%)</span>
<span>-@jobPb.DiscountAmount.ToString("C")</span>
</div>
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">After discount</span>
<span>@jobPb.SubtotalAfterDiscount.ToString("C")</span>
</div>
}
@if (jobPb.RushFee > 0)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Rush fee</span>
<span>@jobPb.RushFee.ToString("C")</span>
</div>
}
@if (jobPb.TaxAmount > 0)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Tax (@jobPb.TaxPercent.ToString("G29")%)</span>
<span>@jobPb.TaxAmount.ToString("C")</span>
</div>
}
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
<span>Total</span>
<span>@jobPb.Total.ToString("C")</span>
</div>
@{
var jobTotalDirectCost = jobPb.MaterialCosts + jobPb.LaborCosts + jobPb.EquipmentCosts + jobPb.OvenBatchCost + jobPb.FacilityOverheadCost + jobPb.ShopSuppliesAmount;
var jobGrossProfit = jobPb.Total - jobTotalDirectCost;
var jobEffectiveMargin = jobPb.Total > 0 ? (jobGrossProfit / jobPb.Total * 100m) : 0m;
}
@if (jobTotalDirectCost > 0)
{
<div class="d-flex justify-content-between small mt-2 pt-1 border-top @(jobEffectiveMargin < 10 ? "text-danger" : jobEffectiveMargin < 20 ? "text-warning" : "text-success")">
<span>Effective gross margin</span>
<span class="fw-semibold">@jobEffectiveMargin.ToString("F1")%</span>
</div>
}
</div>
</div>
}
else
{
@* Fallback: no items yet *@
@if (Model.QuoteId.HasValue)
{
<div class="mb-3">
<label class="text-muted small mb-1">Quoted Price</label>
<p class="mb-0 fw-semibold">@Model.QuotedPrice.ToString("C")</p>
</div>
}
<div>
<label class="text-muted small mb-1">Final Price</label>
<h3 class="mb-0 text-primary">@Model.FinalPrice.ToString("C")</h3>
</div>
}
</div>
</div>
@@ -2584,7 +2834,8 @@
// Notes
const notes = [];
if (!d.hasPowderData) notes.push('⚠ Add powder cost per lb on coat records to include material cost.');
if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push('⚠ Surface area not set on one or more items — edit the item and enter a surface area to calculate powder cost.');
else if (!d.hasPowderData) notes.push('⚠ Add powder cost per lb on coat records to include material cost.');
if (!d.hasLaborData) notes.push('⚠ Log time entries to include labor cost.');
if (d.laborLines?.some(l => l.usingFallback)) notes.push('* One or more workers using standard labor rate fallback.');
document.getElementById('costingNotes').innerHTML = notes.map(n => `<div class="text-muted">${n}</div>`).join('');
@@ -2618,6 +2869,7 @@
<script>
const timeTracking = (() => {
const jid = @Model.Id;
const currentUserId = '@(ViewBag.CurrentUserId ?? "")';
const modal = new bootstrap.Modal(document.getElementById('timeEntryModal'));
let entries = [];
@@ -2673,7 +2925,7 @@
function openAdd() {
document.getElementById('timeEntryModalTitle').textContent = 'Log Time';
document.getElementById('teEntryId').value = '0';
document.getElementById('teWorkerId').value = '';
document.getElementById('teWorkerId').value = currentUserId;
document.getElementById('teWorkDate').value = new Date().toISOString().slice(0, 10);
document.getElementById('teHoursWorked').value = '';
document.getElementById('teStage').value = '';
+1 -1
View File
@@ -285,7 +285,7 @@
</label>
</div>
}
<div class="d-flex gap-2">
<div class="d-flex gap-2 ms-auto">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary btn-lg">
<i class="bi bi-x-circle me-1"></i>Cancel
</a>
@@ -1,6 +1,9 @@
@model PowderCoating.Application.DTOs.Job.JobDto
@{
var emailDefault = ViewBag.EmailDefaultOnComplete == true;
var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary<int, decimal> ?? new Dictionary<int, decimal>();
// Track remaining credit per InventoryItemId as we allocate it across coat rows
var remainingCredit = preLoggedPowder.ToDictionary(kv => kv.Key, kv => kv.Value);
}
<div class="modal fade" id="completeJobModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg">
@@ -76,12 +79,27 @@
<small class="text-muted">@((coat.PowderToOrder ?? 0).ToString("0.##"))</small>
</td>
<td>
@{
decimal preFilledLbs = 0m;
if (coat.InventoryItemId.HasValue && remainingCredit.TryGetValue(coat.InventoryItemId.Value, out var availCredit) && availCredit > 0)
{
preFilledLbs = availCredit;
remainingCredit[coat.InventoryItemId.Value] = 0m;
}
}
<input type="hidden" name="CoatUsages[@coatIndex].JobItemCoatId" value="@coat.Id" />
<input type="number"
class="form-control form-control-sm"
name="CoatUsages[@coatIndex].ActualPowderUsedLbs"
step="0.01" min="0" placeholder="0.00"
value="@(preFilledLbs > 0 ? preFilledLbs.ToString("0.##") : "")"
style="max-width: 120px;">
@if (preFilledLbs > 0)
{
<small class="text-success d-block mt-1">
<i class="bi bi-check-circle me-1"></i>Already logged — inventory adjusted
</small>
}
</td>
</tr>
coatIndex++;
@@ -104,7 +122,7 @@
</div>
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle me-2"></i>
<small>Enter the actual amount of powder used for each coat. Leave blank if not tracked.</small>
<small>Pre-filled values were already logged via scan — inventory is already adjusted for those. You can edit the amount; only the difference will be applied to inventory.</small>
</div>
</div>
}
@@ -90,9 +90,30 @@
<div class="row justify-content-end">
<div class="col-sm-6 col-md-5">
<div class="d-flex justify-content-between mb-1">
<span class="text-muted">Subtotal</span>
<span>@Model.SubTotal.ToString("C")</span>
<span class="text-muted">Items Subtotal</span>
<span>@Model.ItemsSubtotal.ToString("C")</span>
</div>
@if (Model.OvenBatchCost > 0)
{
<div class="d-flex justify-content-between mb-1">
<span class="text-muted">Oven Processing</span>
<span>@Model.OvenBatchCost.ToString("C")</span>
</div>
}
@if (Model.ShopSuppliesAmount > 0)
{
<div class="d-flex justify-content-between mb-1">
<span class="text-muted">Shop Supplies (@(Model.ShopSuppliesPercent.ToString("0.##"))%)</span>
<span>@Model.ShopSuppliesAmount.ToString("C")</span>
</div>
}
@if (Model.OvenBatchCost > 0 || Model.ShopSuppliesAmount > 0)
{
<div class="d-flex justify-content-between mb-1 fw-semibold">
<span>Subtotal</span>
<span>@Model.SubTotal.ToString("C")</span>
</div>
}
@if (Model.DiscountAmount > 0 && !Model.HideDiscountFromCustomer)
{
<div class="d-flex justify-content-between mb-1 text-success">
@@ -207,6 +207,47 @@
</div>
</div>
<!-- SMS Notifications -->
<div class="card mb-4 border-info">
<div class="card-header bg-info bg-opacity-10">
<h5 class="mb-0">
<i class="bi bi-phone me-2"></i>SMS Notifications
</h5>
</div>
<div class="card-body">
<input type="hidden" asp-for="ProspectSmsConsentedAt" />
@if (Model.SmsConsent)
{
<div class="alert alert-success alert-permanent mb-3 py-2">
<i class="bi bi-check-circle me-1"></i>
<strong>SMS consent was recorded on the quote</strong>
@if (Model.ProspectSmsConsentedAt.HasValue)
{
<span>on @Model.ProspectSmsConsentedAt.Value.ToLocalTime().ToString("MM/dd/yyyy")</span>
}
</div>
}
else
{
<div class="alert alert-warning alert-permanent mb-3 py-2">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>No SMS consent was recorded on the quote.</strong>
</div>
}
<div class="form-check">
<input asp-for="SmsConsent" class="form-check-input" id="SmsConsent" />
<label class="form-check-label fw-semibold" for="SmsConsent">
Customer has given verbal consent to receive SMS notifications
</label>
</div>
<small class="text-muted d-block mt-1">
<i class="bi bi-shield-check me-1"></i>
Only check if the customer has explicitly agreed to receive text messages (TCPA compliance).
If checked, the new customer record will have SMS enabled and a confirmation text will be sent.
</small>
</div>
</div>
<!-- Form Actions -->
<div class="mb-4">
<button type="submit" class="btn btn-success btn-lg">
@@ -104,6 +104,19 @@
<span asp-validation-for="ProspectPhone" class="text-danger"></span>
</div>
</div>
<div class="row mt-2" id="prospectSmsConsentRow" style="display:none;">
<div class="col-12">
<div class="form-check">
<input asp-for="ProspectSmsConsent" class="form-check-input" id="ProspectSmsConsent" />
<label class="form-check-label fw-semibold" for="ProspectSmsConsent">
Customer verbally agreed to receive SMS notifications
</label>
</div>
<small class="text-muted">
<i class="bi bi-shield-check me-1"></i>Only check if the customer explicitly consented to receive text messages (TCPA compliance).
</small>
</div>
</div>
<div class="row mt-2 quote-advanced-only">
<div class="col-md-6">
<label asp-for="ProspectAddress" class="form-label"></label>
@@ -764,8 +777,23 @@
if (el) el.removeAttribute('required');
});
}
updateProspectSmsConsentVisibility();
}
function updateProspectSmsConsentVisibility() {
const phoneEl = document.getElementById('prospectPhone');
const row = document.getElementById('prospectSmsConsentRow');
if (!phoneEl || !row) return;
const isProspect = document.getElementById('forProspect')?.checked;
row.style.display = (isProspect && phoneEl.value.trim()) ? '' : 'none';
}
document.addEventListener('DOMContentLoaded', function () {
const phoneEl = document.getElementById('prospectPhone');
if (phoneEl) phoneEl.addEventListener('input', updateProspectSmsConsentVisibility);
updateProspectSmsConsentVisibility();
});
// Discount type toggle
function onDiscountTypeChange() {
const type = document.getElementById('discountTypeSelect').value;
@@ -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' }
})
+26 -1
View File
@@ -52,10 +52,23 @@
</div>
<div class="col-md-6">
<label asp-for="ProspectPhone" class="form-label">Phone <span class="text-danger">*</span></label>
<input asp-for="ProspectPhone" class="form-control" type="tel" />
<input asp-for="ProspectPhone" class="form-control" type="tel" id="editProspectPhone" />
<span asp-validation-for="ProspectPhone" class="text-danger"></span>
</div>
</div>
<div class="row mt-2" id="editProspectSmsConsentRow">
<div class="col-12">
<div class="form-check">
<input asp-for="ProspectSmsConsent" class="form-check-input" id="ProspectSmsConsent" />
<label class="form-check-label fw-semibold" for="ProspectSmsConsent">
Customer verbally agreed to receive SMS notifications
</label>
</div>
<small class="text-muted">
<i class="bi bi-shield-check me-1"></i>Only check if the customer explicitly consented to receive text messages (TCPA compliance).
</small>
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="ProspectAddress" class="form-label"></label>
@@ -683,8 +696,20 @@
if (custEl) new TomSelect(custEl, { placeholder: '-- Select Customer --', openOnFocus: true, maxOptions: false,
onChange: function(value) { onQuoteCustomerChanged({ value: value }); }
});
// Show/hide SMS consent row based on phone field value
const phoneEl = document.getElementById('editProspectPhone');
if (phoneEl) {
phoneEl.addEventListener('input', updateEditProspectSmsConsent);
updateEditProspectSmsConsent();
}
});
function updateEditProspectSmsConsent() {
const phoneEl = document.getElementById('editProspectPhone');
const row = document.getElementById('editProspectSmsConsentRow');
if (phoneEl && row) row.style.display = phoneEl.value.trim() ? '' : 'none';
}
function onQuoteCustomerChanged(select) {
const meta = JSON.parse(document.getElementById('quoteMetaData').textContent);
const optOutIds = new Set(meta.emailOptOutCustomerIds || []);
@@ -0,0 +1,12 @@
(function () {
var select = document.getElementById('IsCommercial');
var row = document.getElementById('billingEmailRow');
if (!select || !row) return;
function toggle() {
row.style.display = select.value === 'true' ? '' : 'none';
}
toggle();
select.addEventListener('change', toggle);
})();
@@ -0,0 +1,56 @@
/**
* Auto-calculates invoice Due Date from Invoice Date + Payment Terms.
* Parses common terms formats: "Net 30", "N/15", "Due on Receipt", "COD", "2% 10 Net 30".
* Only fires when Terms or Invoice Date changes; user can always override the Due Date field.
*/
(function () {
/// <summary>
/// Extracts the net payment days from a free-text terms string.
/// Returns null when the string can't be parsed (due date is left unchanged).
/// </summary>
function parseDays(terms) {
if (!terms || !terms.trim()) return null;
const t = terms.trim().toLowerCase();
if (/\b(receipt|due\s*now|cod|immediate)\b/.test(t)) return 0;
// "Net N" or "N/N" (e.g., "2% 10 Net 30" or "N/30")
let m = t.match(/\bnet\s+(\d+)/) || t.match(/\bn\/(\d+)/);
if (m) return parseInt(m[1], 10);
// Plain number (e.g., "30 days", "30")
m = t.match(/\b(\d+)\b/);
if (m) return parseInt(m[1], 10);
return null;
}
function recalcDueDate() {
const termsEl = document.getElementById('Terms');
const invoiceDateEl = document.getElementById('InvoiceDate');
const dueDateEl = document.getElementById('DueDate');
if (!termsEl || !invoiceDateEl || !dueDateEl) return;
const days = parseDays(termsEl.value);
if (days === null) return;
const rawDate = invoiceDateEl.value;
if (!rawDate) return;
// Parse as local date to avoid UTC-offset shifting the day
const [y, mo, d] = rawDate.split('-').map(Number);
const due = new Date(y, mo - 1, d + days);
const yyyy = due.getFullYear();
const mm = String(due.getMonth() + 1).padStart(2, '0');
const dd = String(due.getDate()).padStart(2, '0');
dueDateEl.value = `${yyyy}-${mm}-${dd}`;
}
document.addEventListener('DOMContentLoaded', function () {
const termsEl = document.getElementById('Terms');
const invoiceDateEl = document.getElementById('InvoiceDate');
if (termsEl) {
termsEl.addEventListener('change', recalcDueDate);
termsEl.addEventListener('blur', recalcDueDate);
}
if (invoiceDateEl) {
invoiceDateEl.addEventListener('change', recalcDueDate);
}
});
})();
+275 -13
View File
@@ -84,7 +84,7 @@ document.addEventListener('DOMContentLoaded', () => {
ownerForm.addEventListener('submit', writeHiddenFields, { capture: true });
}
// Close any open powder combobox dropdown when clicking outside it
// Close any open powder combobox or catalog lookup dropdown when clicking outside it
document.addEventListener('click', e => {
document.querySelectorAll('[id^="coat_powder_wrapper_"]').forEach(wrapper => {
if (!wrapper.contains(e.target)) {
@@ -92,6 +92,12 @@ document.addEventListener('DOMContentLoaded', () => {
powderComboClose(parseInt(idx));
}
});
document.querySelectorAll('[id^="coat_catalog_results_"]').forEach(dd => {
const idx = dd.id.replace('coat_catalog_results_', '');
const wrapper = document.getElementById(`coat_catalog_search_wrapper_${idx}`);
if (!wrapper?.contains(e.target) && !dd.contains(e.target))
dd.style.display = 'none';
});
});
});
@@ -1640,9 +1646,46 @@ function buildCoatRowHtml(i, coat) {
<input type="number" class="form-control" id="coat_costPerLb_${i}" min="0" step="0.01" placeholder="auto" value="${coat.powderCostPerLb || ''}">
</div>
</div>
<!-- Shown only when an incoming (on-order) inventory powder is selected -->
<div class="col-12" id="coat_incoming_section_${i}" style="display:${coat.isIncoming ? 'block' : 'none'}">
<div class="alert alert-warning py-2 mb-0">
<div class="fw-semibold"><i class="bi bi-truck me-1"></i>Incoming / On Order powder not yet in stock</div>
<div class="small mt-1 mb-2">Pricing will charge for the full quantity ordered, not just calculated usage.</div>
<label class="form-label form-label-sm fw-semibold mb-1"><i class="bi bi-cart me-1"></i>Qty to Order (lbs)</label>
<div class="d-flex align-items-center gap-2">
<div class="input-group input-group-sm" style="max-width:200px">
<input type="number" class="form-control" id="coat_incoming_orderQty_${i}" min="0" step="0.01"
placeholder="Lbs to order" value="${coat.isIncoming && coat.powderToOrder ? coat.powderToOrder : ''}">
<span class="input-group-text">lbs</span>
</div>
<span class="text-muted small">Calculated from area: <strong id="coat_incoming_calcQty_${i}"></strong></span>
</div>
</div>
</div>
</div>
<!-- Custom powder -->
<div id="coat_custom_section_${i}" class="row g-2" style="display:${coat.powderType === 'custom' ? 'flex' : 'none'}">
<!-- Catalog lookup row -->
<div class="col-12">
<div class="d-flex align-items-center gap-2">
<div class="input-group input-group-sm flex-grow-1" style="max-width:360px;" id="coat_catalog_search_wrapper_${i}">
<span class="input-group-text bg-white"><i class="bi bi-search text-muted" style="font-size:.8rem;"></i></span>
<input type="text" class="form-control form-control-sm" id="coat_catalog_q_${i}"
placeholder="Lookup from catalog (color name or SKU)…"
oninput="customPowderCatalogInput(${i})"
onkeydown="if(event.key==='Escape'){customPowderCatalogClose(${i})}"
autocomplete="off">
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="customPowderCatalogClose(${i})" title="Clear lookup">
<i class="bi bi-x" style="font-size:.8rem;"></i>
</button>
</div>
<span class="text-muted small fst-italic" style="font-size:.75rem;">or fill in manually below</span>
</div>
<div id="coat_catalog_results_${i}"
class="powder-combo-dropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
</div>
</div>
<div class="col-sm-6">
<label class="form-label form-label-sm">Color Name</label>
<input type="text" class="form-control form-control-sm" id="coat_colorName_${i}" value="${escHtml(coat.colorName || '')}" placeholder="e.g., Gloss Black">
@@ -1679,6 +1722,16 @@ function buildCoatRowHtml(i, coat) {
<input type="number" class="form-control" id="coat_custom_costPerLb_${i}" min="0" step="0.01" placeholder="0.00" value="${coat.powderCostPerLb || ''}">
</div>
</div>
<!-- "Add to inventory as incoming" shown after a catalog selection -->
<div class="col-12" id="coat_custom_incoming_opt_${i}" style="display:${coat.catalogItemId ? 'block' : 'none'}">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="coat_custom_addIncoming_${i}" ${coat.addAsIncoming ? 'checked' : ''}>
<label class="form-check-label small fw-semibold" for="coat_custom_addIncoming_${i}">
<i class="bi bi-truck text-warning me-1"></i>Add to inventory as Incoming Order (enables QR codes on work orders)
</label>
</div>
</div>
<input type="hidden" id="coat_custom_catalogItemId_${i}" value="${coat.catalogItemId || ''}">
<div class="col-12">
<div class="alert alert-warning py-2 mb-0">
<label class="form-label form-label-sm fw-semibold mb-1"><i class="bi bi-cart me-1"></i>Qty to Order (lbs) this powder must be purchased before the job</label>
@@ -1738,6 +1791,15 @@ function restoreCoatRow(i, coat) {
const el = document.getElementById(`coat_custom_orderQty_${i}`);
if (el) el.value = coat.powderToOrder;
}
// Restore incoming state for stock coats backed by an incoming inventory item
if (coat.powderType !== 'custom' && coat.isIncoming) {
const section = document.getElementById(`coat_incoming_section_${i}`);
if (section) section.style.display = 'block';
if (coat.powderToOrder != null) {
const el = document.getElementById(`coat_incoming_orderQty_${i}`);
if (el) el.value = coat.powderToOrder;
}
}
}
function removeCoatRow(i) {
@@ -1769,9 +1831,11 @@ function powderComboInput(i) {
const q = document.getElementById(`coat_powder_search_${i}`)?.value?.toLowerCase() || '';
powderComboRender(i, q);
powderComboShow(i);
// Clear the hidden value when the user edits the text (forces a fresh pick)
// Clear the hidden value and incoming section when the user edits the text (forces a fresh pick)
const hidden = document.getElementById(`coat_inventoryItemId_${i}`);
if (hidden) hidden.value = '';
const incomingSection = document.getElementById(`coat_incoming_section_${i}`);
if (incomingSection) incomingSection.style.display = 'none';
}
function powderComboOpen(i) {
@@ -1798,19 +1862,30 @@ function powderComboRender(i, query) {
? powderData.filter(p => p.text.toLowerCase().includes(query))
: powderData;
if (filtered.length === 0) {
dd.innerHTML = '<div class="px-3 py-2 text-muted small">No powders match your search</div>';
const qEnc = encodeURIComponent(query || '');
dd.innerHTML = `<div class="px-3 py-2 text-muted small">No inventory match.</div>
${query && query.length >= 2 ? `<div class="px-2 pb-2">
<button type="button" class="btn btn-sm btn-outline-warning w-100"
onmousedown="event.preventDefault(); powderCatalogSearch(${i}, '${query.replace(/'/g, "\\'")}')">
<i class="bi bi-search me-1"></i>Search Catalog &amp; Add as Incoming Order
</button>
</div>` : ''}`;
return;
}
dd.innerHTML = filtered.map(p =>
`<div class="powder-opt" style="padding:.35rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
dd.innerHTML = filtered.map(p => {
const badge = p.isIncoming
? '<span class="badge bg-warning text-dark ms-1" style="font-size:.7rem;vertical-align:middle;">Incoming</span>'
: '';
const displayText = p.isIncoming ? p.text.replace(/^\[INCOMING\]\s*/, '') : p.text;
return `<div class="powder-opt" style="padding:.35rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
data-val="${escHtml(String(p.value))}"
data-txt="${escHtml(p.text)}"
onmousedown="event.preventDefault(); powderComboSelect(${i}, this.dataset.val, this.dataset.txt)"
onmouseenter="this.style.background=document.documentElement.getAttribute('data-bs-theme')==='dark'?'#2c3a5a':'#f0f4ff'"
onmouseleave="this.classList.contains('pw-active')?null:this.style.background=''">
${escHtml(p.text)}
</div>`
).join('');
${escHtml(displayText)}${badge}
</div>`;
}).join('');
}
function powderComboShow(i) {
@@ -1869,6 +1944,166 @@ function powderComboKey(event, i) {
}
}
// ─── Custom coat catalog lookup ───────────────────────────────────────────────
let customCatalogDebounce = null;
function customPowderCatalogInput(i) {
clearTimeout(customCatalogDebounce);
const q = document.getElementById(`coat_catalog_q_${i}`)?.value?.trim() || '';
if (q.length < 2) {
// Hide dropdown only — do NOT clear the input (that would erase the user's typing)
const dd = document.getElementById(`coat_catalog_results_${i}`);
if (dd) dd.style.display = 'none';
return;
}
customCatalogDebounce = setTimeout(() => customPowderCatalogSearch(i, q), 300);
}
function customPowderCatalogSearch(i, query) {
const dd = document.getElementById(`coat_catalog_results_${i}`);
if (!dd) return;
const anchor = document.getElementById(`coat_catalog_q_${i}`);
dd.innerHTML = `<div class="px-3 py-2 text-muted small"><i class="bi bi-hourglass-split me-1"></i>Searching…</div>`;
// Position relative to the search input wrapper
const rect = anchor?.closest('.input-group')?.getBoundingClientRect();
if (rect) {
dd.style.position = 'fixed';
dd.style.top = (rect.bottom + 2) + 'px';
dd.style.left = rect.left + 'px';
dd.style.width = rect.width + 'px';
}
dd.style.display = 'block';
fetch(`/Inventory/CatalogLookup?q=${encodeURIComponent(query)}`)
.then(r => r.json())
.then(results => {
if (!results || results.length === 0) {
dd.innerHTML = '<div class="px-3 py-2 text-muted small">No catalog matches. Enter details manually below.</div>';
return;
}
dd.innerHTML = results.map(r => {
const disc = r.isDiscontinued ? '<span class="badge bg-secondary ms-1" style="font-size:.7rem;">Discontinued</span>' : '';
const price = r.unitPrice ? `<span class="text-muted small ms-1">$${parseFloat(r.unitPrice).toFixed(2)}/lb</span>` : '';
return `<div class="powder-opt" style="padding:.4rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
onmousedown="event.preventDefault(); applyCustomCatalogResult(${i}, ${JSON.stringify(r).replace(/"/g, '&quot;')})"
onmouseenter="this.style.background='#f0f4ff'"
onmouseleave="this.style.background=''">
<strong>${escHtml(r.colorName)}</strong> ${escHtml(r.vendorName)}
<span class="text-muted small ms-1">${escHtml(r.sku || '')}</span>
${price}${disc}
</div>`;
}).join('');
})
.catch(() => {
dd.innerHTML = '<div class="px-3 py-2 text-danger small">Search failed. Enter details manually.</div>';
});
}
function customPowderCatalogClose(i) {
const dd = document.getElementById(`coat_catalog_results_${i}`);
if (dd) dd.style.display = 'none';
const qEl = document.getElementById(`coat_catalog_q_${i}`);
if (qEl) qEl.value = '';
}
function applyCustomCatalogResult(i, r) {
customPowderCatalogClose(i);
// Fill in the custom fields from the catalog result
const set = (id, val) => { const el = document.getElementById(id); if (el && val != null) el.value = val; };
set(`coat_colorName_${i}`, r.colorName);
set(`coat_colorCode_${i}`, r.sku || '');
set(`coat_finish_${i}`, r.finish || '');
if (r.coverageSqFtPerLb) set(`coat_custom_coverage_${i}`, r.coverageSqFtPerLb);
if (r.transferEfficiency) set(`coat_custom_efficiency_${i}`, r.transferEfficiency);
if (r.unitPrice) set(`coat_custom_costPerLb_${i}`, parseFloat(r.unitPrice).toFixed(2));
// Store catalog item ID and show "Add to inventory as Incoming" checkbox (default: checked)
set(`coat_custom_catalogItemId_${i}`, r.id);
const incomingOpt = document.getElementById(`coat_custom_incoming_opt_${i}`);
if (incomingOpt) incomingOpt.style.display = 'block';
const addIncomingCheck = document.getElementById(`coat_custom_addIncoming_${i}`);
if (addIncomingCheck) addIncomingCheck.checked = true;
// Try to match catalog vendor name to a local supplier
const vendorLower = (r.vendorName || '').toLowerCase();
if (vendorLower) {
const supplierMatch = supplierData.find(s => {
const sLower = s.text.toLowerCase();
return sLower.includes(vendorLower) || vendorLower.includes(sLower);
});
if (supplierMatch) {
const supplierSel = document.getElementById(`coat_supplierId_${i}`);
if (supplierSel) supplierSel.value = supplierMatch.value;
}
}
updatePowderNeeded(i);
}
// ─── Stock-side catalog search (fallback when no inventory match) ─────────────
/// <summary>
/// Searches the platform powder catalog for items matching the query string and renders
/// them in the dropdown as "Add as Incoming Order" options. If the user clicks one,
/// <see cref="createIncomingFromCatalog"/> POSTs to the server to create a 0-balance
/// inventory item with IsIncoming=true and then selects it for the current coat.
/// </summary>
function powderCatalogSearch(i, query) {
const dd = document.getElementById(`coat_powder_dropdown_${i}`);
if (!dd) return;
dd.innerHTML = `<div class="px-3 py-2 text-muted small"><i class="bi bi-hourglass-split me-1"></i>Searching catalog…</div>`;
powderComboShow(i);
fetch(`/Inventory/CatalogLookup?q=${encodeURIComponent(query)}`)
.then(r => r.json())
.then(results => {
if (!results || results.length === 0) {
dd.innerHTML = '<div class="px-3 py-2 text-muted small">No catalog matches found. Try a different search term.</div>';
return;
}
dd.innerHTML = `<div class="px-3 py-1 text-muted small fw-semibold border-bottom" style="font-size:.75rem;">Catalog Results — click to add as Incoming Order</div>` +
results.map(r => {
const label = r.isDiscontinued
? `<span class="badge bg-secondary ms-1" style="font-size:.7rem;">Discontinued</span>`
: '';
return `<div class="powder-opt" style="padding:.35rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
onmousedown="event.preventDefault(); createIncomingFromCatalog(${i}, ${r.id})"
onmouseenter="this.style.background='#fff8e1'"
onmouseleave="this.style.background=''">
<i class="bi bi-truck text-warning me-1"></i>
<strong>${escHtml(r.colorName)}</strong> ${escHtml(r.vendorName)} ${escHtml(r.sku || '')}
<span class="text-muted small ms-1">$${parseFloat(r.unitPrice || 0).toFixed(2)}/lb</span>${label}
</div>`;
}).join('');
})
.catch(() => {
dd.innerHTML = '<div class="px-3 py-2 text-danger small">Catalog search failed. Please try again.</div>';
});
}
function createIncomingFromCatalog(i, catalogItemId) {
powderComboClose(i);
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
const searchEl = document.getElementById(`coat_powder_search_${i}`);
if (searchEl) searchEl.value = 'Adding to inventory…';
const body = new URLSearchParams({ catalogItemId, __RequestVerificationToken: token || '' });
fetch('/Inventory/CreateIncomingFromCatalog', { method: 'POST', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded' } })
.then(r => r.json())
.then(data => {
if (!data.success) {
if (searchEl) searchEl.value = '';
alert(data.error || 'Failed to create inventory item.');
return;
}
// Add the new item to powderData so it can be found by onPowderSelected
powderData.push(data);
// Select it as the current coat's powder
powderComboSelect(i, data.value, data.text);
})
.catch(() => {
if (searchEl) searchEl.value = '';
alert('Failed to create inventory item. Please try again.');
});
}
function onPowderSelected(i) {
const sel = document.getElementById(`coat_inventoryItemId_${i}`);
if (!sel || !sel.value) return;
@@ -1880,6 +2115,11 @@ function onPowderSelected(i) {
if (covEl) covEl.value = powder.coverage;
if (effEl) effEl.value = powder.efficiency;
if (costEl && powder.costPerLb) costEl.value = parseFloat(powder.costPerLb).toFixed(2);
// Show the incoming-order-qty section when the selected powder is incoming
const incomingSection = document.getElementById(`coat_incoming_section_${i}`);
if (incomingSection) incomingSection.style.display = powder.isIncoming ? 'block' : 'none';
updatePowderNeeded(i);
}
@@ -1899,9 +2139,14 @@ function updatePowderNeeded(i) {
const valEl = document.getElementById(`coat_powderNeededVal_${i}`);
if (valEl) valEl.textContent = lbs.toFixed(2) + ' lbs';
// Update the suggested qty label next to the custom order qty input
// Update the suggested qty labels for custom and incoming order qty inputs
const calcQtyEl = document.getElementById(`coat_custom_calcQty_${i}`);
if (calcQtyEl) calcQtyEl.textContent = lbs.toFixed(2) + ' lbs';
const incomingCalcEl = document.getElementById(`coat_incoming_calcQty_${i}`);
if (incomingCalcEl) incomingCalcEl.textContent = lbs.toFixed(2) + ' lbs';
// Pre-fill incoming order qty if empty
const incomingQtyEl = document.getElementById(`coat_incoming_orderQty_${i}`);
if (incomingQtyEl && !incomingQtyEl.value) incomingQtyEl.value = lbs.toFixed(2);
}
function updateAllPowderNeeded() {
@@ -2238,15 +2483,20 @@ function collectStep3() {
if (!isCustom) {
const invId = document.getElementById(`coat_inventoryItemId_${i}`)?.value;
coat.inventoryItemId = invId ? parseInt(invId) : null;
// Resolve color name from powderData for display purposes
// Resolve color name and incoming flag from powderData for display purposes
let isIncomingCoat = false;
if (coat.inventoryItemId) {
const powder = powderData.find(p => p.value === String(invId));
if (powder) coat.colorName = powder.colorName || null;
if (powder) {
coat.colorName = powder.colorName || null;
isIncomingCoat = powder.isIncoming || false;
}
}
coat.coverageSqFtPerLb = parseFloat(document.getElementById(`coat_coverage_${i}`)?.value) || 30;
coat.transferEfficiency = parseFloat(document.getElementById(`coat_efficiency_${i}`)?.value) || 65;
const costEl = document.getElementById(`coat_costPerLb_${i}`)?.value;
coat.powderCostPerLb = costEl ? parseFloat(costEl) : null;
coat.isIncoming = isIncomingCoat;
} else {
coat.colorName = document.getElementById(`coat_colorName_${i}`)?.value?.trim() || null;
coat.colorCode = document.getElementById(`coat_colorCode_${i}`)?.value?.trim() || null;
@@ -2257,12 +2507,19 @@ function collectStep3() {
coat.transferEfficiency = parseFloat(document.getElementById(`coat_custom_efficiency_${i}`)?.value) || 65;
const costEl = document.getElementById(`coat_custom_costPerLb_${i}`)?.value;
coat.powderCostPerLb = costEl ? parseFloat(costEl) : null;
// Catalog lookup result fields
const catId = document.getElementById(`coat_custom_catalogItemId_${i}`)?.value;
coat.catalogItemId = catId ? parseInt(catId) : null;
coat.addAsIncoming = document.getElementById(`coat_custom_addIncoming_${i}`)?.checked || false;
}
// Powder to order: custom coats read from the user-entered field; stock coats auto-calculate
// Powder to order: custom/incoming coats read from the user-entered field; in-stock auto-calculates
if (isCustom) {
const orderQtyVal = document.getElementById(`coat_custom_orderQty_${i}`)?.value;
coat.powderToOrder = orderQtyVal ? parseFloat(orderQtyVal) : null;
} else if (coat.isIncoming) {
const orderQtyVal = document.getElementById(`coat_incoming_orderQty_${i}`)?.value;
coat.powderToOrder = orderQtyVal ? parseFloat(orderQtyVal) : null;
} else {
const sqft = parseFloat(wz.data.surfaceAreaSqFt) || 0;
const qty = parseInt(wz.data.quantity) || 1;
@@ -2295,7 +2552,10 @@ function preFillStep2() {
if (wz.itemType === 'product' && d.catalogItemId) {
const listItem = document.querySelector(`#catalogListbox [data-value="${d.catalogItemId}"]`);
if (listItem) pickCatalogItem(listItem);
if (listItem) {
pickCatalogItem(listItem);
listItem.scrollIntoView({ block: 'nearest' });
}
}
if (wz.itemType === 'calculated') {
@@ -2534,6 +2794,8 @@ function writeHiddenFields() {
if (coat.powderToOrder) fields.push(h(cp + '.PowderToOrder', coat.powderToOrder));
if (coat.notes) fields.push(h(cp + '.Notes', coat.notes));
fields.push(h(cp + '.NoExtraLayerCharge', coat.noExtraLayerCharge ? 'true' : 'false'));
if (coat.catalogItemId) fields.push(h(cp + '.CatalogItemId', coat.catalogItemId));
if (coat.addAsIncoming) fields.push(h(cp + '.AddAsIncoming', 'true'));
});
});