Compare commits

...

10 Commits

Author SHA1 Message Date
spouliot 9a52e7fae5 Ad-hoc quote email, accounting improvements, AI lookup fix, and misc service updates
- Quotes: ad-hoc email modal on Quote Details lets staff send to an address not on file;
  QuotesController passes overrideEmail through to NotificationService
- Quotes/Details view: SMS consent display, email/SMS send button state based on consent
- Accounting module: AccountingDisplayHelpers for consistent ledger formatting;
  AccountsController + Accounts views improvements; AccountingEnums additions
- Bills/Expenses: AI account categorization fixes in BillsController and ExpensesController
- InventoryAiLookupService: TDS cure fallback no longer fires on AiAugmentFromUrl path
  (LookupByUrlAsync already has it built in — was double-fetching)
- PdfService: quote/invoice PDF updates
- PricingCalculationService: minor pricing logic fix
- QuoteProfile: mapping updates for new quote fields
- ApplicationDbContextModelSnapshot: catches up to all 4 migrations in this branch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:48:00 -04:00
spouliot 0d980e651a Add pricing breakdown and powder pre-fill to Job Details; surface voided invoice history
- Job Details: collapsible internal pricing breakdown card mirrors quote details breakdown
  (items subtotal, shop supplies, discount, rush fee, tax, total)
- Job Details: voided invoice history section shows previous invoices instead of hiding them
- Complete Job modal: pre-fills powder usage from QR-scanned / manually logged entries so
  staff don't double-log; consumes pre-logged credit per InventoryItemId before deducting net delta
- JobProfile: map ShopSuppliesAmount, ShopSuppliesPercent, IsRushJob, DiscountType/Value/Reason
  so the pricing breakdown has the data it needs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:47:44 -04:00
spouliot 3278152d83 Fix invoice re-creation after void; add payment terms selector and shop supplies line
- Voided invoices no longer block creating a new invoice for the same job: voided invoice's
  JobId FK is cleared so the unique index slot is freed for the replacement
- Invoice Details view shows voided invoices as history rather than hiding them
- Payment terms: standardized SelectList (Due on Receipt, Net 15/30/45/60/90, 2% 10 Net 30,
  COD) with custom-term preservation; invoice-due-date.js auto-updates Due Date on term change
- Shop supplies on direct (no-quote) jobs: InvoicesController derives the shop supplies line
  from the company rate when the job has no source quote to read the pre-agreed amount from
- Job entity: ShopSuppliesAmount + ShopSuppliesPercent fields preserved through job lifecycle
- Migration: AddShopSuppliesAmountToJob

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:47:34 -04:00
spouliot fc35fd123c Add IsIncoming inventory flag and catalog-to-incoming powder flow in item wizard
- InventoryItem.IsIncoming: marks powder ordered but not yet received; enables QR code
  printing on work orders while the shipment is in transit
- InventoryController.CreateIncomingFromCatalog: POST endpoint creates a 0-balance inventory
  record from a PowderCatalogItem and returns it in wizard-compatible shape
- item-wizard.js: custom coat tab now searches the platform powder catalog as a fallback;
  catalog results show an 'Add as Incoming Order' option; createIncomingFromCatalog POSTs
  to server and selects the new item without a page refresh
- QuoteItemCoatDto: CatalogItemId + AddAsIncoming fields so the wizard can signal server-side
  incoming-item creation during quote save
- Inventory Create/Edit/Index views: IsIncoming badge and field
- IInventoryAiLookupService: minor interface update
- Migration: AddInventoryIsIncoming

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:47:19 -04:00
spouliot f40d58ac2e Add TCPA-compliant SMS consent tracking for prospect quotes
- Quote entity: ProspectSmsConsent (bool) + ProspectSmsConsentedAt (DateTime?) fields
- QuoteDtos: consent fields on Create/Update/Convert DTOs with TCPA guidance text
- Quote Create/Edit views: SMS consent checkbox shown when mobile number is entered
- Quote ConvertToCustomer view: staff must re-confirm consent carries over to customer record
- QuoteApproval: consent state exposed in ViewModel and ApprovalPage for transparency
- Consent timestamp cleared when prospect quote is linked to an existing customer
- Migration: AddProspectSmsConsent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:47:04 -04:00
spouliot fb979bc88d Add BillingEmail field for commercial customers; support comma-separated multi-email
- Customer entity + DTO: new BillingEmail field (accounting/invoicing address)
- Email fields now accept comma-separated lists; DTO validates each address individually
- NotificationService: SendToEmailListAsync helper fans out to all addresses in a list;
  NotifyQuoteSentAsync accepts optional overrideEmail so staff can send to an ad-hoc address
- Migration: AddCustomerBillingEmail
- Customer Create/Edit/Details views updated to show Billing Email field
- customer-billing-email.js: client-side helpers for billing email input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:46:53 -04:00
spouliot 12f784f34c Add Unit Price column to quote PDF
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:41:44 -04:00
spouliot 90f93b6e2f Fall back to ProspectEmail for CustomerEmail on prospect quotes
Prospect quotes have no CustomerId so Customer is null — email is stored
in ProspectEmail directly on the quote. The send-button visibility check
was always seeing null and showing the 'no contact info' warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:31:12 -04:00
spouliot 135fd6f8d7 Clarify no-contact warning to say 'mobile number' not 'phone'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:06:24 -04:00
spouliot ff231d9dd2 Set quote status to Converted and show job number link on quote details
- CreateJobFromQuote now sets QuoteStatusId to CONVERTED after creating the job
- Added ConvertedToJobNumber to QuoteDto, populated in Details action
- 'View Job' button on Quote Details now shows the job number (e.g. 'View Job JOB-2505-0001')

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:47:45 -04:00
64 changed files with 40549 additions and 282 deletions
+3 -1
View File
@@ -172,7 +172,9 @@
"Bash(Select-Object -First 20)", "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'\\)\")", "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)", "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)"
] ]
} }
} }
+31 -4
View File
@@ -1,9 +1,13 @@
Shop Management App TO DO List Shop Management App TO DO List
============================== ==============================
-Inventory Lookup not always finding price for Columbia Coatings -Add feature to prep for events where we can generate coupons or gift certificates in bulk
-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 Duplication refactor memory
-Still random weird characters on a bunch of pages. Intake button for example on the jobs screen shows: Intake ✓ 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 -Google review request email after a job
@@ -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 -Lookup Modal not showing ALL matches. Maybe make scrollable
-Pickup cure information from TDS Sheet if not found by AI Search -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 -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 Ideas Removed
======================= =======================
+32 -3
View File
@@ -1,8 +1,33 @@
Shop Management App TO DO List Shop Management App TO DO List
============================== ==============================
-Inventory Lookup not always finding price for Columbia Coatings -When editing a job/quote item from catalog, pre-select the item chosen please
-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself -Move buttons to right side of job details page
-Need to allow deleting of powder usage entries, or at least editing in case of a goof up -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 -Google review request email after a job
-Check my ChatGPT chat about surface area for a few solid ideas for the system -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 -Lookup Modal not showing ALL matches. Maybe make scrollable
-Pickup cure information from TDS Sheet if not found by AI Search -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 -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 Ideas Removed
======================= =======================
@@ -9,6 +9,7 @@ public class CustomerDto
public string? ContactFirstName { get; set; } public string? ContactFirstName { get; set; }
public string? ContactLastName { get; set; } public string? ContactLastName { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public string? BillingEmail { get; set; }
public string? Phone { get; set; } public string? Phone { get; set; }
public string? MobilePhone { get; set; } public string? MobilePhone { get; set; }
public string? Address { get; set; } public string? Address { get; set; }
@@ -52,10 +53,13 @@ public class CreateCustomerDto : IValidatableObject
public string? ContactLastName { get; set; } public string? ContactLastName { get; set; }
[Display(Name = "Email")] [Display(Name = "Email")]
[EmailAddress(ErrorMessage = "Please enter a valid email address")] [StringLength(1000)]
[StringLength(200)]
public string? Email { get; set; } public string? Email { get; set; }
[Display(Name = "Billing / Accounting Email")]
[StringLength(1000)]
public string? BillingEmail { get; set; }
[Display(Name = "Phone")] [Display(Name = "Phone")]
[Phone(ErrorMessage = "Please enter a valid phone number")] [Phone(ErrorMessage = "Please enter a valid phone number")]
[StringLength(20)] [StringLength(20)]
@@ -143,6 +147,33 @@ public class CreateCustomerDto : IValidatableObject
"Please provide at least one contact method (Email or Phone)", "Please provide at least one contact method (Email or Phone)",
new[] { nameof(Email), nameof(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? Location { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public bool IsIncoming { get; set; }
public DateTime? DiscontinuedDate { get; set; } public DateTime? DiscontinuedDate { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public bool IsLowStock { get; set; } public bool IsLowStock { get; set; }
@@ -74,6 +75,7 @@ public class InventoryListDto
public int? PrimaryVendorId { get; set; } public int? PrimaryVendorId { get; set; }
public string? PrimaryVendorName { get; set; } public string? PrimaryVendorName { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public bool IsIncoming { get; set; }
public bool IsLowStock { get; set; } public bool IsLowStock { get; set; }
public bool IsOutOfStock { get; set; } public bool IsOutOfStock { get; set; }
public bool HasSamplePanel { get; set; } public bool HasSamplePanel { get; set; }
@@ -217,6 +219,9 @@ public class CreateInventoryItemDto
[Display(Name = "Sample Panel on Wall")] [Display(Name = "Sample Panel on Wall")]
public bool HasSamplePanel { get; set; } public bool HasSamplePanel { get; set; }
[Display(Name = "Incoming / On Order")]
public bool IsIncoming { get; set; }
} }
public class UpdateInventoryItemDto : CreateInventoryItemDto public class UpdateInventoryItemDto : CreateInventoryItemDto
@@ -45,6 +45,12 @@ public class JobDto
public decimal QuotedPrice { get; set; } public decimal QuotedPrice { get; set; }
public decimal FinalPrice { 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? CustomerPO { get; set; }
public string? SpecialInstructions { get; set; } public string? SpecialInstructions { get; set; }
public string? InternalNotes { get; set; } public string? InternalNotes { get; set; }
@@ -59,6 +59,8 @@ public class QuoteDto
public string? ProspectCity { get; set; } public string? ProspectCity { get; set; }
public string? ProspectState { get; set; } public string? ProspectState { get; set; }
public string? ProspectZipCode { get; set; } public string? ProspectZipCode { get; set; }
public bool ProspectSmsConsent { get; set; }
public DateTime? ProspectSmsConsentedAt { get; set; }
public string? PreparedById { get; set; } public string? PreparedById { get; set; }
public string? PreparedByName { get; set; } public string? PreparedByName { get; set; }
@@ -127,6 +129,7 @@ public class QuoteDto
// Conversion Tracking // Conversion Tracking
public int? ConvertedToJobId { get; set; } public int? ConvertedToJobId { get; set; }
public string? ConvertedToJobNumber { get; set; }
// Customer Approval Tracking // Customer Approval Tracking
public string? ApprovalToken { get; set; } public string? ApprovalToken { get; set; }
@@ -185,6 +188,9 @@ public class CreateQuoteDto
[StringLength(10)] [StringLength(10)]
public string? ProspectZipCode { get; set; } public string? ProspectZipCode { get; set; }
[Display(Name = "SMS Consent")]
public bool ProspectSmsConsent { get; set; } = false;
// Oven Selection // Oven Selection
[Display(Name = "Oven")] [Display(Name = "Oven")]
public int? OvenCostId { get; set; } public int? OvenCostId { get; set; }
@@ -321,6 +327,9 @@ public class UpdateQuoteDto
[StringLength(10)] [StringLength(10)]
public string? ProspectZipCode { get; set; } public string? ProspectZipCode { get; set; }
[Display(Name = "SMS Consent")]
public bool ProspectSmsConsent { get; set; } = false;
// Oven Selection // Oven Selection
[Display(Name = "Oven")] [Display(Name = "Oven")]
public int? OvenCostId { get; set; } public int? OvenCostId { get; set; }
@@ -684,6 +693,16 @@ public class ConvertQuoteToCustomerDto
[Display(Name = "Notes")] [Display(Name = "Notes")]
[DataType(DataType.MultilineText)] [DataType(DataType.MultilineText)]
public string? Notes { get; set; } 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; }
} }
// ============================================================================ // ============================================================================
@@ -744,6 +763,16 @@ public class CreateQuoteItemCoatDto
/// When true, the additional layer labor charge is not applied even if this is not the first coat. /// When true, the additional layer labor charge is not applied even if this is not the first coat.
/// </summary> /// </summary>
public bool NoExtraLayerCharge { get; set; } 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> /// <summary>
@@ -47,8 +47,10 @@ public interface IInventoryAiLookupService
/// <summary> /// <summary>
/// Fetch cure specs, color families, finish, and clear-coat data from a known product URL. /// 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. /// 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> /// </summary>
Task<InventoryAiLookupResult> LookupByUrlAsync(string url, string? colorName); Task<InventoryAiLookupResult> LookupByUrlAsync(string url, string? colorName, string? tdsFallbackUrl = null);
/// <summary> /// <summary>
/// Read a powder label photo and extract manufacturer, color name, SKU, and cure specs /// 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. /// Notify when a quote is created/sent. Handles both registered customers and prospects.
/// Optionally attaches the quote PDF to the email. /// Optionally attaches the quote PDF to the email.
/// </summary> /// </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> /// <summary>
/// Sends the quote approval link to the customer via SMS. /// 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. /// Notify customer when an invoice has been sent.
/// Optionally includes an online payment link in the email body. /// Optionally includes an online payment link in the email body.
/// </summary> /// </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> /// <summary>
/// Notify customer (internal) when a payment has been recorded on an invoice. /// 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.CompanyName
: $"{s.Customer.ContactFirstName} {s.Customer.ContactLastName}".Trim()) : $"{s.Customer.ContactFirstName} {s.Customer.ContactLastName}".Trim())
: string.Empty)) : 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.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.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null .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 => .ForMember(dest => dest.PrepServiceIds, opt => opt.MapFrom(src =>
src.JobPrepServices.Select(jps => jps.PrepServiceId).ToList())) src.JobPrepServices.Select(jps => jps.PrepServiceId).ToList()))
.ForMember(dest => dest.TimeEntries, opt => opt.MapFrom(src => src.TimeEntries)) .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.IsReworkJob, opt => opt.MapFrom(src => src.IsReworkJob))
.ForMember(dest => dest.OriginalJobId, opt => opt.MapFrom(src => src.OriginalJobId)) .ForMember(dest => dest.OriginalJobId, opt => opt.MapFrom(src => src.OriginalJobId))
.ForMember(dest => dest.OriginalJobNumber, .ForMember(dest => dest.OriginalJobNumber,
@@ -35,7 +35,7 @@ public class QuoteProfile : Profile
.ForMember(dest => dest.CustomerName, opt => opt.MapFrom(src => .ForMember(dest => dest.CustomerName, opt => opt.MapFrom(src =>
src.Customer != null ? src.Customer.CompanyName : null)) src.Customer != null ? src.Customer.CompanyName : null))
.ForMember(dest => dest.CustomerEmail, opt => opt.MapFrom(src => .ForMember(dest => dest.CustomerEmail, opt => opt.MapFrom(src =>
src.Customer != null ? src.Customer.Email : null)) src.Customer != null ? src.Customer.Email : src.ProspectEmail))
.ForMember(dest => dest.CustomerMobilePhone, opt => opt.MapFrom(src => .ForMember(dest => dest.CustomerMobilePhone, opt => opt.MapFrom(src =>
src.Customer != null ? src.Customer.MobilePhone : null)) src.Customer != null ? src.Customer.MobilePhone : null))
.ForMember(dest => dest.CustomerNotifyBySms, opt => opt.MapFrom(src => .ForMember(dest => dest.CustomerNotifyBySms, opt => opt.MapFrom(src =>
@@ -78,6 +78,7 @@ public class QuoteProfile : Profile
// CreateQuoteDto -> Quote // CreateQuoteDto -> Quote
CreateMap<CreateQuoteDto, Quote>() CreateMap<CreateQuoteDto, Quote>()
.ForMember(dest => dest.Id, opt => opt.Ignore()) .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.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.QuoteStatus, opt => opt.Ignore()) // Will be set by FK to Draft status
.ForMember(dest => dest.OvenCost, opt => opt.Ignore()) .ForMember(dest => dest.OvenCost, opt => opt.Ignore())
@@ -111,6 +112,7 @@ public class QuoteProfile : Profile
// UpdateQuoteDto -> Quote // UpdateQuoteDto -> Quote
CreateMap<UpdateQuoteDto, Quote>() CreateMap<UpdateQuoteDto, Quote>()
.ForMember(dest => dest.QuoteNumber, opt => opt.Ignore()) // Cannot change .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.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.QuoteStatus, opt => opt.Ignore()) // Will be set by FK
.ForMember(dest => dest.OvenCost, opt => opt.Ignore()) .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.ZipCode, opt => opt.MapFrom(src => src.ProspectZipCode))
.ForMember(dest => dest.IsCommercial, opt => opt.MapFrom(src => src.IsCommercial)) .ForMember(dest => dest.IsCommercial, opt => opt.MapFrom(src => src.IsCommercial))
.ForMember(dest => dest.CreditLimit, opt => opt.MapFrom(src => 0m)) .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.PricingTierId, opt => opt.Ignore())
.ForMember(dest => dest.TaxId, opt => opt.Ignore()) .ForMember(dest => dest.TaxId, opt => opt.Ignore())
.ForMember(dest => dest.PaymentTerms, 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.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Header().Element(c => ComposeInvoiceHeader(c, companyLogo, companyInfo, accentColor, invoiceDto)); 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 => page.Footer().AlignCenter().Text(text =>
{ {
text.CurrentPageNumber(); text.CurrentPageNumber();
@@ -153,7 +158,6 @@ public class PdfService : IPdfService
if (invoice.DueDate.HasValue) if (invoice.DueDate.HasValue)
column.Item().Text($"Due: {invoice.DueDate.Value:MMMM d, yyyy}").FontSize(9).FontColor( 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); 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> /// <summary>
/// Composes the body of the invoice PDF: bill-to address block, job reference, alternating-row /// 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, /// line-item table, and a right-aligned totals block that conditionally shows discount, tax,
@@ -640,12 +665,13 @@ public class PdfService : IPdfService
{ {
column.Item().PaddingTop(10).Table(table => column.Item().PaddingTop(10).Table(table =>
{ {
// Define columns: Description, Color, Qty, Total // Define columns: Description, Color, Qty, Unit Price, Total
table.ColumnsDefinition(columns => table.ColumnsDefinition(columns =>
{ {
columns.RelativeColumn(4); // Description columns.RelativeColumn(4); // Description
columns.RelativeColumn(2); // Color columns.RelativeColumn(2); // Color
columns.ConstantColumn(40); // Qty columns.ConstantColumn(40); // Qty
columns.ConstantColumn(80); // Unit Price
columns.ConstantColumn(80); // Total columns.ConstantColumn(80); // Total
}); });
@@ -655,6 +681,7 @@ public class PdfService : IPdfService
header.Cell().Background(accentColor).Padding(5).Text("Description").FontSize(9).Bold().FontColor(Colors.White); header.Cell().Background(accentColor).Padding(5).Text("Description").FontSize(9).Bold().FontColor(Colors.White);
header.Cell().Background(accentColor).Padding(5).Text("Color").FontSize(9).Bold().FontColor(Colors.White); header.Cell().Background(accentColor).Padding(5).Text("Color").FontSize(9).Bold().FontColor(Colors.White);
header.Cell().Background(accentColor).Padding(5).Text("Qty").FontSize(9).Bold().FontColor(Colors.White); header.Cell().Background(accentColor).Padding(5).Text("Qty").FontSize(9).Bold().FontColor(Colors.White);
header.Cell().Background(accentColor).Padding(5).AlignRight().Text("Unit Price").FontSize(9).Bold().FontColor(Colors.White);
header.Cell().Background(accentColor).Padding(5).AlignRight().Text("Total").FontSize(9).Bold().FontColor(Colors.White); header.Cell().Background(accentColor).Padding(5).AlignRight().Text("Total").FontSize(9).Bold().FontColor(Colors.White);
}); });
@@ -757,6 +784,9 @@ public class PdfService : IPdfService
// Quantity (centered) // Quantity (centered)
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.Quantity.ToString()).FontSize(9); table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.Quantity.ToString()).FontSize(9);
// Unit Price (right-aligned)
table.Cell().Background(bgColor).Padding(5).AlignRight().Text($"${item.UnitPrice:N2}").FontSize(9);
// Total (right-aligned, bold) // Total (right-aligned, bold)
table.Cell().Background(bgColor).Padding(5).AlignRight().Text($"${item.TotalPrice:N2}").FontSize(9).Bold(); table.Cell().Background(bgColor).Padding(5).AlignRight().Text($"${item.TotalPrice:N2}").FontSize(9).Bold();
@@ -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. // 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. // 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 bool isCustomPowder = !coat.InventoryItemId.HasValue
&& coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.HasValue
&& coat.PowderCostPerLb.Value > 0; && coat.PowderCostPerLb.Value > 0;
bool isIncomingPowder = false;
if (coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0) 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) else if (coat.InventoryItemId.HasValue && coat.InventoryItemId.Value > 0)
{ {
// In-stock powder - use inventory cost // In-stock or incoming powder - use inventory cost
try try
{ {
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value); var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
if (inventoryItem != null && inventoryItem.UnitCost > 0) if (inventoryItem != null && inventoryItem.UnitCost > 0)
{ {
costPerLb = inventoryItem.UnitCost; costPerLb = inventoryItem.UnitCost;
isIncomingPowder = inventoryItem.IsIncoming;
var coverage = coat.CoverageSqFtPerLb; var coverage = coat.CoverageSqFtPerLb;
var transferEfficiency = coat.TransferEfficiency; var transferEfficiency = coat.TransferEfficiency;
@@ -157,8 +160,8 @@ public class PricingCalculationService : IPricingCalculationService
var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m); var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m);
powderCostPerSqFt = actualPoundsPerSqFt * costPerLb; powderCostPerSqFt = actualPoundsPerSqFt * costPerLb;
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem}, UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft", _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, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt); coat.CoatName, inventoryItem.Name, isIncomingPowder, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -172,13 +175,13 @@ public class PricingCalculationService : IPricingCalculationService
var batchSurfaceAreaSqFt = perItemSurfaceAreaSqFt * quantity; var batchSurfaceAreaSqFt = perItemSurfaceAreaSqFt * quantity;
decimal coatMaterialCost; 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; 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})", _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, coat.PowderToOrder.Value, costPerLb, coatMaterialCost, batchSurfaceAreaSqFt * powderCostPerSqFt); coat.CoatName, isIncomingPowder ? "Incoming" : "Custom", coat.PowderToOrder.Value, costPerLb, coatMaterialCost, batchSurfaceAreaSqFt * powderCostPerSqFt);
} }
else if (batchSurfaceAreaSqFt > 0) else if (batchSurfaceAreaSqFt > 0)
{ {
@@ -6,6 +6,7 @@ public class Customer : BaseEntity
public string? ContactFirstName { get; set; } public string? ContactFirstName { get; set; }
public string? ContactLastName { get; set; } public string? ContactLastName { get; set; }
public string? Email { 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? Phone { get; set; }
public string? MobilePhone { get; set; } public string? MobilePhone { get; set; }
public string? Address { get; set; } public string? Address { get; set; }
@@ -58,6 +58,13 @@ public class InventoryItem : BaseEntity
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public DateTime? DiscontinuedDate { get; set; } 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 ────────────────────────────────────────── // ── Financial Account Mapping ──────────────────────────────────────────
/// <summary> /// <summary>
+2
View File
@@ -28,6 +28,8 @@ public class Job : BaseEntity
// Pricing // Pricing
public decimal QuotedPrice { get; set; } public decimal QuotedPrice { get; set; }
public decimal FinalPrice { 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) // Discount & rush (mirrors quote fields; preserved through quote→job conversion and job edits)
public bool IsRushJob { get; set; } public bool IsRushJob { get; set; }
+3
View File
@@ -17,6 +17,9 @@ public class Quote : BaseEntity
public string? ProspectCity { get; set; } public string? ProspectCity { get; set; }
public string? ProspectState { get; set; } public string? ProspectState { get; set; }
public string? ProspectZipCode { 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) // Lookup foreign key (replacing enum)
public int QuoteStatusId { get; set; } public int QuoteStatusId { get; set; }
@@ -13,6 +13,7 @@ public enum AccountType
public enum AccountSubType public enum AccountSubType
{ {
// Assets // Assets
Cash = 8,
Checking = 1, Checking = 1,
Savings = 2, Savings = 2,
AccountsReceivable = 3, 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") b.Property<string>("Address")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("BillingEmail")
.HasColumnType("nvarchar(max)");
b.Property<string>("City") b.Property<string>("City")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -3279,6 +3282,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("IsIncoming")
.HasColumnType("bit");
b.Property<DateTime?>("LastPurchaseDate") b.Property<DateTime?>("LastPurchaseDate")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -3844,6 +3850,12 @@ namespace PowderCoating.Infrastructure.Migrations
.HasColumnType("uniqueidentifier") .HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWID()"); .HasDefaultValueSql("NEWID()");
b.Property<decimal>("ShopSuppliesAmount")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("ShopSuppliesPercent")
.HasColumnType("decimal(18,2)");
b.Property<int?>("ShopWorkerId") b.Property<int?>("ShopWorkerId")
.HasColumnType("int"); .HasColumnType("int");
@@ -6059,7 +6071,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, 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", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -6070,7 +6082,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, 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", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -6081,7 +6093,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, 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", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -6397,6 +6409,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("ProspectPhone") b.Property<string>("ProspectPhone")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<bool>("ProspectSmsConsent")
.HasColumnType("bit");
b.Property<DateTime?>("ProspectSmsConsentedAt")
.HasColumnType("datetime2");
b.Property<string>("ProspectState") b.Property<string>("ProspectState")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -407,7 +407,7 @@ Rules:
/// known product page URL without running a Serper search. Used after a catalog hit /// 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. /// to augment the catalog record with fields the catalog table doesn't store.
/// </summary> /// </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"]; var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-")) if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
@@ -484,6 +484,28 @@ Rules:
}; };
ApplyPowderFallbacks(result); 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; return result;
} }
catch (Exception ex) catch (Exception ex)
@@ -70,7 +70,7 @@ public class NotificationService : INotificationService
/// - Customer: respects NotifyByEmail; still writes a Skipped log if opted out. /// - Customer: respects NotifyByEmail; still writes a Skipped log if opted out.
/// Always writes a NotificationLog row so the Notifications Sent tab shows delivery history. /// Always writes a NotificationLog row so the Notifications Sent tab shows delivery history.
/// </summary> /// </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 try
{ {
@@ -80,7 +80,8 @@ public class NotificationService : INotificationService
// Prospect quote (no customer record yet) // Prospect quote (no customer record yet)
if (quote.CustomerId == null) if (quote.CustomerId == null)
{ {
if (string.IsNullOrWhiteSpace(quote.ProspectEmail)) var prospectEmail = !string.IsNullOrWhiteSpace(overrideEmail) ? overrideEmail : quote.ProspectEmail;
if (string.IsNullOrWhiteSpace(prospectEmail))
return; return;
var prospectName = !string.IsNullOrWhiteSpace(quote.ProspectContactName) var prospectName = !string.IsNullOrWhiteSpace(quote.ProspectContactName)
@@ -97,7 +98,7 @@ public class NotificationService : INotificationService
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync( var (success, error) = await _emailService.SendEmailAsync(
quote.ProspectEmail, prospectName, subject, plainText, fullHtml, prospectEmail, prospectName, subject, plainText, fullHtml,
pdfAttachment, pdfFilename, "application/pdf", pdfAttachment, pdfFilename, "application/pdf",
replyToEmail, replyToName); replyToEmail, replyToName);
@@ -107,7 +108,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.QuoteSent, NotificationType = NotificationType.QuoteSent,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed, Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = prospectName, RecipientName = prospectName,
Recipient = quote.ProspectEmail, Recipient = prospectEmail,
Subject = subject, Subject = subject,
Message = plainText, Message = plainText,
ErrorMessage = error, ErrorMessage = error,
@@ -124,7 +125,11 @@ public class NotificationService : INotificationService
var customerName = GetCustomerDisplayName(customer); 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 baseUrl = await GetBaseUrlAsync();
var values = BuildQuoteSentValues(companyName, customerName, quote, baseUrl); var values = BuildQuoteSentValues(companyName, customerName, quote, baseUrl);
@@ -135,8 +140,8 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml, emailToUse, customerName, subject, plainText, fullHtml,
pdfAttachment, pdfFilename, "application/pdf", pdfAttachment, pdfFilename, "application/pdf",
replyToEmail, replyToName); replyToEmail, replyToName);
@@ -146,7 +151,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.QuoteSent, NotificationType = NotificationType.QuoteSent,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed, Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName, RecipientName = customerName,
Recipient = customer.Email, Recipient = recipientsLog,
Subject = subject, Subject = subject,
Message = plainText, Message = plainText,
ErrorMessage = error, ErrorMessage = error,
@@ -156,10 +161,10 @@ public class NotificationService : INotificationService
CompanyId = quote.CompanyId CompanyId = quote.CompanyId
}); });
} }
else if (!string.IsNullOrWhiteSpace(customer.Email)) else if (quoteEmails.Count > 0)
{ {
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.QuoteSent, 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) catch (Exception ex)
@@ -195,7 +200,9 @@ public class NotificationService : INotificationService
if (quote.CustomerId == null) 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; smsPhone = quote.ProspectPhone;
if (string.IsNullOrWhiteSpace(smsPhone)) if (string.IsNullOrWhiteSpace(smsPhone))
return (false, "No phone number on file for this prospect."); 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 (replyToEmail, replyToName) = await GetEmailFromAsync(quote.CompanyId);
var customerName = GetCustomerDisplayName(customer); 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> var values = new Dictionary<string, string>
{ {
@@ -295,7 +303,7 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml, customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName); replyToEmail: replyToEmail, replyToName: replyToName);
@@ -305,7 +313,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.QuoteApproved, NotificationType = NotificationType.QuoteApproved,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed, Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName, RecipientName = customerName,
Recipient = customer.Email, Recipient = recipientsLog,
Subject = subject, Subject = subject,
Message = plainText, Message = plainText,
ErrorMessage = error, ErrorMessage = error,
@@ -315,10 +323,10 @@ public class NotificationService : INotificationService
CompanyId = quote.CompanyId CompanyId = quote.CompanyId
}); });
} }
else if (!string.IsNullOrWhiteSpace(customer.Email)) else if (approvedEmails.Count > 0)
{ {
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.QuoteApproved, 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) catch (Exception ex)
@@ -349,7 +357,8 @@ public class NotificationService : INotificationService
: NotificationType.JobStatusChanged; : NotificationType.JobStatusChanged;
// Email for all status changes // 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; // 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. // 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 fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml, customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName); replyToEmail: replyToEmail, replyToName: replyToName);
@@ -387,7 +396,7 @@ public class NotificationService : INotificationService
NotificationType = notifType, NotificationType = notifType,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed, Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName, RecipientName = customerName,
Recipient = customer.Email, Recipient = recipientsLog,
Subject = subject, Subject = subject,
Message = plainText, Message = plainText,
ErrorMessage = error, ErrorMessage = error,
@@ -397,10 +406,10 @@ public class NotificationService : INotificationService
CompanyId = job.CompanyId CompanyId = job.CompanyId
}); });
} }
else if (!string.IsNullOrWhiteSpace(customer.Email)) else if (statusEmails.Count > 0)
{ {
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.JobStatusChanged, 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); var customerName = GetCustomerDisplayName(customer);
// Email // 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> var values = new Dictionary<string, string>
{ {
@@ -444,7 +454,7 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml, customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName); replyToEmail: replyToEmail, replyToName: replyToName);
@@ -454,7 +464,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.JobCompleted, NotificationType = NotificationType.JobCompleted,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed, Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName, RecipientName = customerName,
Recipient = customer.Email, Recipient = recipientsLog,
Subject = subject, Subject = subject,
Message = plainText, Message = plainText,
ErrorMessage = error, ErrorMessage = error,
@@ -464,10 +474,10 @@ public class NotificationService : INotificationService
CompanyId = job.CompanyId CompanyId = job.CompanyId
}); });
} }
else if (!string.IsNullOrWhiteSpace(customer.Email)) else if (completedEmails.Count > 0)
{ {
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.JobCompleted, 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 // 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 /// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a
/// standard "here is your invoice" message with no payment CTA. /// standard "here is your invoice" message with no payment CTA.
/// </summary> /// </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 try
{ {
@@ -622,7 +632,15 @@ public class NotificationService : INotificationService
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId); var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
var customerName = GetCustomerDisplayName(customer); 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 var dueText = invoice.DueDate.HasValue
? $" Payment is due by {invoice.DueDate.Value:MMMM d, yyyy}." ? $" 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(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
: StripHtml(fullHtml); : StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml, invoiceEmail, customerName, subject, plainText, fullHtml,
pdfAttachment, pdfFilename, "application/pdf", pdfAttachment, pdfFilename, "application/pdf",
replyToEmail, replyToName); replyToEmail, replyToName);
@@ -672,7 +690,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.InvoiceSent, NotificationType = NotificationType.InvoiceSent,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed, Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName, RecipientName = customerName,
Recipient = customer.Email, Recipient = recipientsLog,
Subject = subject, Subject = subject,
Message = plainText, Message = plainText,
ErrorMessage = error, ErrorMessage = error,
@@ -682,10 +700,10 @@ public class NotificationService : INotificationService
CompanyId = invoice.CompanyId CompanyId = invoice.CompanyId
}); });
} }
else if (!string.IsNullOrWhiteSpace(customer.Email)) else if (invoiceEmails.Count > 0)
{ {
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent, 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) catch (Exception ex)
@@ -710,7 +728,8 @@ public class NotificationService : INotificationService
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId); var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
var customerName = GetCustomerDisplayName(customer); 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 var balanceText = invoice.BalanceDue > 0
? $" Remaining balance: {invoice.BalanceDue:C}." ? $" Remaining balance: {invoice.BalanceDue:C}."
@@ -733,7 +752,7 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml, customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName); replyToEmail: replyToEmail, replyToName: replyToName);
@@ -743,7 +762,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.PaymentReceived, NotificationType = NotificationType.PaymentReceived,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed, Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName, RecipientName = customerName,
Recipient = customer.Email, Recipient = recipientsLog,
Subject = subject, Subject = subject,
Message = plainText, Message = plainText,
ErrorMessage = error, ErrorMessage = error,
@@ -753,10 +772,10 @@ public class NotificationService : INotificationService
CompanyId = invoice.CompanyId CompanyId = invoice.CompanyId
}); });
} }
else if (!string.IsNullOrWhiteSpace(customer.Email)) else if (paymentEmails.Count > 0)
{ {
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.PaymentReceived, 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) catch (Exception ex)
@@ -782,7 +801,8 @@ public class NotificationService : INotificationService
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId); var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
var customerName = GetCustomerDisplayName(customer); 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 var dueDate = invoice.DueDate.HasValue
? invoice.DueDate.Value.ToString("MMMM d, yyyy") ? 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 fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml, customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName); replyToEmail: replyToEmail, replyToName: replyToName);
@@ -816,7 +836,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.PaymentReminder, NotificationType = NotificationType.PaymentReminder,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed, Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName, RecipientName = customerName,
Recipient = customer.Email, Recipient = recipientsLog,
Subject = subject, Subject = subject,
Message = plainText, Message = plainText,
ErrorMessage = error, ErrorMessage = error,
@@ -826,10 +846,10 @@ public class NotificationService : INotificationService
CompanyId = invoice.CompanyId CompanyId = invoice.CompanyId
}); });
} }
else if (!string.IsNullOrWhiteSpace(customer.Email)) else if (reminderEmails.Count > 0)
{ {
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.PaymentReminder, 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) catch (Exception ex)
@@ -1324,7 +1344,7 @@ public class NotificationService : INotificationService
{ {
var customer = invoice.Customer var customer = invoice.Customer
?? await _context.Customers.AsNoTracking().FirstOrDefaultAsync(c => c.Id == invoice.CustomerId); ?? 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 customerName = GetCustomerDisplayName(customer);
var company = await _context.Companies.AsNoTracking() 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> <p style='color:#6b7280;font-size:14px;'>Thank you for your business,<br/><strong>{System.Net.WebUtility.HtmlEncode(companyName)}</strong></p>
</div>"; </div>";
await _emailService.SendEmailAsync(customer.Email, customerName, subject, plain, html, await SendToEmailListAsync(customer.Email, customerName, subject, plain, html,
replyToEmail: replyToEmail, replyToName: replyToName); replyToEmail: replyToEmail, replyToName: replyToName);
} }
catch (Exception ex) catch (Exception ex)
@@ -1389,7 +1409,7 @@ Thank you for your business,
{ {
// Determine recipient — linked customer or prospect contact // Determine recipient — linked customer or prospect contact
string? toEmail = quote.Customer?.Email ?? quote.ProspectEmail; string? toEmail = quote.Customer?.Email ?? quote.ProspectEmail;
if (string.IsNullOrWhiteSpace(toEmail)) return; if (ParseEmailList(toEmail).Count == 0) return;
string customerName; string customerName;
if (quote.Customer != null) 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> <p style='color:#6b7280;font-size:14px;'>Thank you for your business,<br/><strong>{System.Net.WebUtility.HtmlEncode(companyName)}</strong></p>
</div>"; </div>";
await _emailService.SendEmailAsync(toEmail, customerName, subject, plain, html, await SendToEmailListAsync(toEmail, customerName, subject, plain, html,
replyToEmail: replyToEmail, replyToName: replyToName); replyToEmail: replyToEmail, replyToName: replyToName);
} }
catch (Exception ex) 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) private static string GetCustomerDisplayName(Customer customer)
{ {
if (!customer.IsCommercial) if (!customer.IsCommercial)
@@ -9,6 +9,7 @@ using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers;
namespace PowderCoating.Web.Controllers; namespace PowderCoating.Web.Controllers;
@@ -88,8 +89,8 @@ public class AccountsController : Controller
dto.AccountSubType = preSubType.Value; dto.AccountSubType = preSubType.Value;
dto.AccountType = preSubType.Value switch dto.AccountType = preSubType.Value switch
{ {
AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable AccountSubType.Cash or AccountSubType.Checking or AccountSubType.Savings
or AccountSubType.Inventory or AccountSubType.FixedAsset or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset or AccountSubType.OtherAsset => AccountType.Asset, or AccountSubType.OtherCurrentAsset or AccountSubType.OtherAsset => AccountType.Asset,
AccountSubType.AccountsPayable or AccountSubType.CreditCard AccountSubType.AccountsPayable or AccountSubType.CreditCard
or AccountSubType.OtherCurrentLiability or AccountSubType.LongTermLiability => AccountType.Liability, or AccountSubType.OtherCurrentLiability or AccountSubType.LongTermLiability => AccountType.Liability,
@@ -445,11 +446,11 @@ public class AccountsController : Controller
.ToList(); .ToList();
ViewBag.AccountTypes = Enum.GetValues<AccountType>() ViewBag.AccountTypes = Enum.GetValues<AccountType>()
.Select(t => new SelectListItem(t.ToString(), ((int)t).ToString())) .Select(t => new SelectListItem(t.ToDisplayName(), ((int)t).ToString()))
.ToList(); .ToList();
ViewBag.AccountSubTypes = Enum.GetValues<AccountSubType>() ViewBag.AccountSubTypes = Enum.GetValues<AccountSubType>()
.Select(t => new SelectListItem(t.ToString(), ((int)t).ToString())) .Select(t => new SelectListItem(t.ToDisplayName(), ((int)t).ToString()))
.ToList(); .ToList();
} }
} }
@@ -424,7 +424,8 @@ public class BillsController : Controller
// Payment form defaults // Payment form defaults
var bankAccounts = (await _unitOfWork.Accounts.FindAsync( 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.Savings ||
a.AccountSubType == AccountSubType.CreditCard)) a.AccountSubType == AccountSubType.CreditCard))
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
@@ -949,7 +950,8 @@ public class BillsController : Controller
.ToList(); .ToList();
ViewBag.BankAccounts = allAccounts 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.Savings ||
a.AccountSubType == AccountSubType.CreditCard) a.AccountSubType == AccountSubType.CreditCard)
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
@@ -401,7 +401,8 @@ public class ExpensesController : Controller
.ToList(); .ToList();
ViewBag.PaymentAccounts = allAccounts 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.Savings ||
a.AccountSubType == AccountSubType.CreditCard) a.AccountSubType == AccountSubType.CreditCard)
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
@@ -708,7 +708,6 @@ public class InventoryController : Controller
return Json(new { success = false, errorMessage = "No product URL provided." }); return Json(new { success = false, errorMessage = "No product URL provided." });
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName); var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
if (result.Success) await ApplyTdsCureFallbackAsync(result, colorName);
return Json(result); return Json(result);
} }
@@ -801,17 +800,15 @@ public class InventoryController : Controller
} }
/// <summary> /// <summary>
/// If cure temperature or cure time is still missing after the primary lookup but a TDS URL /// When cure specs are still missing after a primary AI lookup (LookupAsync or ScanLabelAsync),
/// was returned, fetches that page and asks Claude to extract only the cure schedule. /// fetches the TDS URL that Claude returned and asks it to extract only the cure schedule.
/// Mutates <paramref name="result"/> in place; silently no-ops on failure so callers /// Not used by AiAugmentFromUrl — that path uses LookupByUrlAsync which has TDS fallback built in.
/// can always return the result even if the TDS fetch does not help.
/// </summary> /// </summary>
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName) private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
{ {
if ((result.CureTemperatureF == null || result.CureTimeMinutes == null) if ((result.CureTemperatureF == null || result.CureTimeMinutes == null)
&& !string.IsNullOrEmpty(result.TdsUrl)) && !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); var tds = await _aiLookupService.FetchTdsCureSpecsAsync(result.TdsUrl, colorName);
if (tds.Success) if (tds.Success)
{ {
@@ -1118,6 +1115,109 @@ public class InventoryController : Controller
return Json(results); 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) private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
{ {
return transferEfficiency ?? DefaultTransferEfficiency; return transferEfficiency ?? DefaultTransferEfficiency;
@@ -51,6 +51,30 @@ public class InvoicesController : Controller
_logoService = logoService; _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 // 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); var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems);
if (job == null) return NotFound(); if (job == null) return NotFound();
// Validate no existing invoice for this job // Validate no existing active invoice for this job (voided ones are kept as history)
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value, includeDeleted: true); var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value);
if (existing != null) if (existing != null && existing.Status != InvoiceStatus.Voided)
return RedirectToAction(nameof(Details), new { id = existing.Id }); return RedirectToAction(nameof(Details), new { id = existing.Id });
dto.JobId = job.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. // 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 // 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 // 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 // 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. // their FinalPrice already includes the tax that was applied in the source environment.
if (!dto.InvoiceItems.Any()) if (!hadJobItems)
{ {
var defaultRevAccId = defaultRevenueAccount?.Id; var defaultRevAccId = defaultRevenueAccount?.Id;
dto.InvoiceItems.Add(new CreateInvoiceItemDto dto.InvoiceItems.Add(new CreateInvoiceItemDto
@@ -431,6 +458,26 @@ public class InvoicesController : Controller
dto.TaxPercent = sourceQuote.TaxPercent; dto.TaxPercent = sourceQuote.TaxPercent;
dto.DiscountAmount = sourceQuote.DiscountAmount; 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 // Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
if (job.Customer?.IsTaxExempt == true) if (job.Customer?.IsTaxExempt == true)
@@ -444,7 +491,7 @@ public class InvoicesController : Controller
: string.Empty; : string.Empty;
} }
await PopulateCreateViewBagAsync(currentUser.CompanyId); await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms);
ViewBag.GuidedActivation = guidedActivation; ViewBag.GuidedActivation = guidedActivation;
return View(dto); return View(dto);
} }
@@ -485,7 +532,7 @@ public class InvoicesController : Controller
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
await PopulateCreateViewBagAsync(currentUser.CompanyId); await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms);
ViewBag.GuidedActivation = guidedActivation; ViewBag.GuidedActivation = guidedActivation;
return View(dto); return View(dto);
} }
@@ -493,21 +540,32 @@ public class InvoicesController : Controller
if (!dto.InvoiceItems.Any()) if (!dto.InvoiceItems.Any())
{ {
ModelState.AddModelError("", "Please add at least one line item before saving."); ModelState.AddModelError("", "Please add at least one line item before saving.");
await PopulateCreateViewBagAsync(currentUser.CompanyId); await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms);
ViewBag.GuidedActivation = guidedActivation; ViewBag.GuidedActivation = guidedActivation;
return View(dto); 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) 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) if (existing != null)
{ {
ModelState.AddModelError("", "An invoice already exists for this job."); if (existing.Status != InvoiceStatus.Voided)
await PopulateCreateViewBagAsync(currentUser.CompanyId); {
ViewBag.GuidedActivation = guidedActivation; ModelState.AddModelError("", "An invoice already exists for this job.");
return View(dto); 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"); _logger.LogError(ex, "Error creating invoice");
TempData["Error"] = "An error occurred while creating the invoice."; TempData["Error"] = "An error occurred while creating the invoice.";
var currentUser = await _userManager.GetUserAsync(User); 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; ViewBag.GuidedActivation = guidedActivation;
return View(dto); return View(dto);
} }
@@ -694,9 +752,9 @@ public class InvoicesController : Controller
// GET: /Invoices/Edit/5 // GET: /Invoices/Edit/5
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// <summary> /// <summary>
/// Loads the Edit form. Only Draft invoices are editable — any other status redirects to /// Loads the Edit form. Draft, Sent, and Overdue invoices are editable. Paid, PartiallyPaid,
/// Details with an error. Sent/Paid/Voided invoices must be voided and recreated rather /// Voided, and WrittenOff invoices are locked — those statuses represent committed financial
/// than edited, to preserve the audit trail for those states. /// records that should not be altered after the fact.
/// </summary> /// </summary>
public async Task<IActionResult> Edit(int? id) public async Task<IActionResult> Edit(int? id)
{ {
@@ -707,9 +765,9 @@ public class InvoicesController : Controller
var invoice = await LoadInvoiceForViewAsync(id.Value); var invoice = await LoadInvoiceForViewAsync(id.Value);
if (invoice == null) return NotFound(); 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 }); return RedirectToAction(nameof(Details), new { id });
} }
@@ -748,6 +806,11 @@ public class InvoicesController : Controller
? invoice.Customer.CompanyName ? invoice.Customer.CompanyName
: $"{invoice.Customer.ContactFirstName} {invoice.Customer.ContactLastName}".Trim()) : $"{invoice.Customer.ContactFirstName} {invoice.Customer.ContactLastName}".Trim())
: string.Empty; : 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); return View(dto);
} }
@@ -763,23 +826,23 @@ public class InvoicesController : Controller
// POST: /Invoices/Edit/5 // POST: /Invoices/Edit/5
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// <summary> /// <summary>
/// Saves edits to a Draft invoice. Line items are replaced via a soft-delete-and-add cycle /// Saves edits to an open invoice (Draft, Sent, or Overdue). Line items are replaced via a
/// (old items flagged IsDeleted, new items inserted) so the audit trail of what was originally /// soft-delete-and-add cycle so the original items are preserved in the audit trail.
/// on the invoice is preserved in the database. Customer.CurrentBalance is adjusted by the /// Customer.CurrentBalance is adjusted by the delta (newTotal oldTotal). Status is kept
/// delta (newTotal oldTotal) so outstanding AR stays accurate without recalculating from scratch. /// as-is (Sent stays Sent) so the customer-facing record remains consistent. If resendToCustomer
/// Only Draft invoices can be edited; guard is checked on both GET and POST. /// is true and the invoice is Sent/Overdue, a fresh PDF is emailed to the customer.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateInvoiceDto dto) public async Task<IActionResult> Edit(int id, UpdateInvoiceDto dto, bool resendToCustomer = false)
{ {
try try
{ {
var invoice = await LoadInvoiceForViewAsync(id); var invoice = await LoadInvoiceForViewAsync(id);
if (invoice == null) return NotFound(); 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 }); return RedirectToAction(nameof(Details), new { id });
} }
@@ -789,6 +852,7 @@ public class InvoicesController : Controller
ViewBag.InvoiceId = invoice.Id; ViewBag.InvoiceId = invoice.Id;
ViewBag.JobNumber = invoice.Job?.JobNumber; ViewBag.JobNumber = invoice.Job?.JobNumber;
ViewBag.CustomerName = invoice.Customer?.CompanyName; ViewBag.CustomerName = invoice.Customer?.CompanyName;
ViewBag.PaymentTermsOptions = BuildPaymentTermsSelectList(dto.Terms);
return View(dto); return View(dto);
} }
@@ -862,6 +926,28 @@ public class InvoicesController : Controller
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
TempData["Success"] = "Invoice updated successfully."; 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 }); return RedirectToAction(nameof(Details), new { id });
} }
catch (Exception ex) catch (Exception ex)
@@ -883,7 +969,7 @@ public class InvoicesController : Controller
/// works identically in dev (localhost) and production without config changes. /// works identically in dev (localhost) and production without config changes.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Send(int id) public async Task<IActionResult> Send(int id, string? overrideEmail = null)
{ {
try try
{ {
@@ -916,7 +1002,7 @@ public class InvoicesController : Controller
try try
{ {
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId); 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; pdfAndNotifSucceeded = true;
} }
catch (Exception notifyEx) catch (Exception notifyEx)
@@ -1296,7 +1382,7 @@ public class InvoicesController : Controller
/// <see cref="BuildInvoicePdfAsync"/> which fetches company branding, template settings, /// <see cref="BuildInvoicePdfAsync"/> which fetches company branding, template settings,
/// and the full invoice DTO in one call, then hands off to IPdfService. /// and the full invoice DTO in one call, then hands off to IPdfService.
/// </summary> /// </summary>
public async Task<IActionResult> DownloadPdf(int? id) public async Task<IActionResult> DownloadPdf(int? id, bool inline = false)
{ {
if (id == null) return NotFound(); if (id == null) return NotFound();
@@ -1309,7 +1395,17 @@ public class InvoicesController : Controller
if (currentUser == null) return Unauthorized(); if (currentUser == null) return Unauthorized();
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser.CompanyId); 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) catch (Exception ex)
{ {
@@ -1336,9 +1432,10 @@ public class InvoicesController : Controller
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized(); 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(Details), new { id = existing.Id });
return RedirectToAction(nameof(Create), new { jobId }); 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. /// Details view can show an inline toast with the delivery outcome.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ResendInvoice(int id) public async Task<IActionResult> ResendInvoice(int id, string? overrideEmail = null)
{ {
try try
{ {
@@ -1375,11 +1472,21 @@ public class InvoicesController : Controller
if (invoice.Status == InvoiceStatus.Voided || invoice.Status == InvoiceStatus.WrittenOff) if (invoice.Status == InvoiceStatus.Voided || invoice.Status == InvoiceStatus.WrittenOff)
return Json(new { success = false, message = "Voided invoices cannot be resent." }); 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 currentUser = await _userManager.GetUserAsync(User);
var recipientName = invoice.Customer?.IsCommercial == true var recipientName = invoice.Customer?.IsCommercial == true
? invoice.Customer.CompanyName ?? "Customer" ? invoice.Customer.CompanyName ?? "Customer"
: $"{invoice.Customer?.ContactFirstName} {invoice.Customer?.ContactLastName}".Trim(); : $"{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; byte[]? pdfBytes = null;
string? pdfFilename = 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); _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); var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
@@ -1403,7 +1510,7 @@ public class InvoicesController : Controller
if (latestLog?.Status == NotificationStatus.Skipped) 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 = 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) catch (Exception ex)
{ {
@@ -1500,6 +1607,10 @@ public class InvoicesController : Controller
if (invoice.TaxAmount > 0) if (invoice.TaxAmount > 0)
await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount); 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); await _unitOfWork.Invoices.SoftDeleteAsync(id);
}); // end ExecuteInTransactionAsync }); // 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. /// — 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. /// — Merchandise catalog items serialized as camelCase JSON for the invoice line-item picker modal.
/// </summary> /// </summary>
private async Task PopulateCreateViewBagAsync(int companyId) private async Task PopulateCreateViewBagAsync(int companyId, string? selectedTerms = null)
{ {
var customers = await _unitOfWork.Customers.GetAllAsync(); var customers = await _unitOfWork.Customers.GetAllAsync();
ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList(); 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) .Select(c => c.Id)
.ToHashSet(); .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) // Merchandise items for the invoice merch picker (all active IsMerchandise items)
var allMerchItems = await _unitOfWork.CatalogItems.FindAsync( var allMerchItems = await _unitOfWork.CatalogItems.FindAsync(
i => i.IsMerchandise && i.IsActive, false, i => i.Category); i => i.IsMerchandise && i.IsActive, false, i => i.Category);
@@ -1787,7 +1904,9 @@ public class InvoicesController : Controller
private async Task PopulateBankAccountsAsync() private async Task PopulateBankAccountsAsync()
{ {
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive 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 ViewBag.BankAccounts = accounts
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString())) .Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
@@ -395,11 +395,17 @@ public class JobsController : Controller
ViewBag.UseMetric = useMetric; ViewBag.UseMetric = useMetric;
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
// Check if an invoice exists for this job // Separate active invoice from voided history for this job
var jobInvoice = await _unitOfWork.Invoices.GetForJobAsync(id.Value); 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.JobInvoiceId = jobInvoice?.Id;
ViewBag.JobInvoiceNumber = jobInvoice?.InvoiceNumber; ViewBag.JobInvoiceNumber = jobInvoice?.InvoiceNumber;
ViewBag.JobInvoiceStatus = jobInvoice?.Status; ViewBag.JobInvoiceStatus = jobInvoice?.Status;
ViewBag.JobVoidedInvoices = voidedInvoices;
// Workers dropdown for inline assignment // Workers dropdown for inline assignment
await PopulateWorkersDropdown(); await PopulateWorkersDropdown();
@@ -410,11 +416,79 @@ public class JobsController : Controller
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
.ToListAsync(); .ToListAsync();
ViewBag.ShopWorkers = companyUsers.Select(u => new { Id = u.Id, Name = u.FullName }).ToList(); 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) // Populate Edit Items wizard data (inline modal on Details page)
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId); var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m); await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m; 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.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
ViewBag.ComplexityModeratePercent = wizardCosts?.ComplexityModeratePercent ?? 5m; ViewBag.ComplexityModeratePercent = wizardCosts?.ComplexityModeratePercent ?? 5m;
ViewBag.ComplexityComplexPercent = wizardCosts?.ComplexityComplexPercent ?? 15m; ViewBag.ComplexityComplexPercent = wizardCosts?.ComplexityComplexPercent ?? 15m;
@@ -465,9 +539,15 @@ public class JobsController : Controller
ViewBag.Deposits = jobDeposits; ViewBag.Deposits = jobDeposits;
// Materials used on this job via QR scan or manual log // 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)) t => t.JobId == id.Value, false, t => t.InventoryItem))
.OrderByDescending(t => t.TransactionDate).ToList(); .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 // Job photo subscription limits — used to disable the upload button in the view
var photoCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; var photoCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
@@ -1153,7 +1233,9 @@ public class JobsController : Controller
createCosts?.TaxPercent ?? 0m, createCosts?.TaxPercent ?? 0m,
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null); 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; job.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.Jobs.UpdateAsync(job);
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
@@ -1667,7 +1749,9 @@ public class JobsController : Controller
dto.JobItems, companyId, dto.CustomerId, dto.JobItems, companyId, dto.CustomerId,
editCosts?.TaxPercent ?? 0m, editCosts?.TaxPercent ?? 0m,
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null); 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 // Save change history records
@@ -2702,6 +2786,15 @@ public class JobsController : Controller
job.JobStatusId = completedStatus.Id; 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 // Update actual powder usage for each coat
foreach (var coatUsage in dto.CoatUsages) foreach (var coatUsage in dto.CoatUsages)
{ {
@@ -2723,37 +2816,49 @@ public class JobsController : Controller
coatUsage.ActualPowderUsedLbs.HasValue && coatUsage.ActualPowderUsedLbs.HasValue &&
coatUsage.ActualPowderUsedLbs.Value > 0) coatUsage.ActualPowderUsedLbs.Value > 0)
{ {
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(jobItemCoat.InventoryItemId.Value); var invItemId = jobItemCoat.InventoryItemId.Value;
if (inventoryItem != null) 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 inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
var transaction = new InventoryTransaction if (inventoryItem != null)
{ {
InventoryItemId = inventoryItem.Id, var transaction = new InventoryTransaction
TransactionType = InventoryTransactionType.JobUsage, {
Quantity = -coatUsage.ActualPowderUsedLbs.Value, // Negative for deduction InventoryItemId = inventoryItem.Id,
UnitCost = inventoryItem.UnitCost, TransactionType = InventoryTransactionType.JobUsage,
TotalCost = inventoryItem.UnitCost * coatUsage.ActualPowderUsedLbs.Value, Quantity = -deductNow,
TransactionDate = DateTime.UtcNow, UnitCost = inventoryItem.UnitCost,
JobId = job.Id, TotalCost = inventoryItem.UnitCost * deductNow,
Reference = job.JobNumber, TransactionDate = DateTime.UtcNow,
Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}", JobId = job.Id,
BalanceAfter = inventoryItem.QuantityOnHand - coatUsage.ActualPowderUsedLbs.Value, Reference = job.JobNumber,
CompanyId = job.CompanyId 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); await _unitOfWork.InventoryTransactions.AddAsync(transaction);
inventoryItem.QuantityOnHand -= deductNow;
// Update inventory item quantity await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
inventoryItem.QuantityOnHand -= coatUsage.ActualPowderUsedLbs.Value;
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( _logger.LogInformation(
"Deducted {Lbs} lbs of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}", "Skipped inventory deduction for JobItemCoat {CoatId} — {Lbs} lbs already pre-logged for inventory item {InvItemId}",
coatUsage.ActualPowderUsedLbs.Value, coatUsage.JobItemCoatId, actualLbs, invItemId);
inventoryItem.Name,
job.JobNumber,
inventoryItem.QuantityOnHand);
} }
} }
} }
@@ -3113,7 +3218,9 @@ public class JobsController : Controller
model.JobItems, currentUser.CompanyId, job.CustomerId, model.JobItems, currentUser.CompanyId, job.CustomerId,
model.TaxPercent, "None", 0, false, null, 1, null); 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.UpdatedAt = DateTime.UtcNow;
job.UpdatedBy = currentUser.UserName; job.UpdatedBy = currentUser.UserName;
await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.Jobs.UpdateAsync(job);
@@ -3184,11 +3291,15 @@ public class JobsController : Controller
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
remainingDtos, currentUser.CompanyId, job.CustomerId, remainingDtos, currentUser.CompanyId, job.CustomerId,
costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null); 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 else
{ {
job.FinalPrice = 0; job.FinalPrice = 0;
job.ShopSuppliesAmount = 0;
job.ShopSuppliesPercent = 0;
} }
job.UpdatedAt = DateTime.UtcNow; job.UpdatedAt = DateTime.UtcNow;
@@ -3211,18 +3322,21 @@ public class JobsController : Controller
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory); var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
ViewBag.InventoryCoatings = inventory ViewBag.InventoryCoatings = inventory
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating) .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 .Select(i => new
{ {
value = i.Id.ToString(), 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, coverage = i.CoverageSqFtPerLb ?? 30m,
efficiency = i.TransferEfficiency ?? 65m, efficiency = i.TransferEfficiency ?? 65m,
unitOfMeasure = i.UnitOfMeasure ?? "lbs", unitOfMeasure = i.UnitOfMeasure ?? "lbs",
categoryName = i.InventoryCategory.DisplayName, categoryName = i.InventoryCategory!.DisplayName,
costPerLb = i.UnitCost, costPerLb = i.UnitCost,
colorName = i.ColorName ?? i.Name, colorName = i.ColorName ?? i.Name,
colorCode = i.ColorCode ?? "" colorCode = i.ColorCode ?? "",
isIncoming = i.IsIncoming
}).ToList(); }).ToList();
var vendors = await _unitOfWork.Vendors.GetAllAsync(false); 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 // Update pricing from quote and advance the snapshot so banner clears
job.QuotedPrice = quote.Total; job.QuotedPrice = quote.Total;
job.FinalPrice = quote.Total; job.FinalPrice = quote.Total;
job.QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt; job.ShopSuppliesAmount = quote.ShopSuppliesAmount;
job.ShopSuppliesPercent = quote.ShopSuppliesPercent;
job.QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt;
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
}); });
@@ -3933,7 +4049,8 @@ public class JobsController : Controller
// Operating costs for fallback labor rate and oven rate // Operating costs for fallback labor rate and oven rate
var opCosts = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault(); var opCosts = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
var fallbackLaborRate = opCosts?.StandardLaborRate ?? 0m; 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 // Role cost rates map: role → hourly rate
var roleCosts = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId); var roleCosts = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId);
@@ -3942,6 +4059,7 @@ public class JobsController : Controller
// 1. Powder / Material cost // 1. Powder / Material cost
decimal powderCost = 0m; decimal powderCost = 0m;
var powderLines = new List<object>(); var powderLines = new List<object>();
bool hasCoatsWithRateButNoQty = false;
foreach (var item in job.JobItems) foreach (var item in job.JobItems)
{ {
foreach (var coat in item.Coats) foreach (var coat in item.Coats)
@@ -3960,6 +4078,11 @@ public class JobsController : Controller
isActual = coat.ActualPowderUsedLbs.HasValue 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), laborCost = Math.Round(laborCost, 2),
ovenCost = Math.Round(ovenCost, 2), ovenCost = Math.Round(ovenCost, 2),
ovenLabel, ovenLabel,
ovenCycleMinutes = opCosts?.DefaultOvenCycleMinutes ?? 45, ovenCycleMinutes = effectiveOvenMinutes,
reworkCostTotal = Math.Round(reworkCostTotal, 2), reworkCostTotal = Math.Round(reworkCostTotal, 2),
reworkBilledToCustomer = Math.Round(reworkBilledToCustomer, 2), reworkBilledToCustomer = Math.Round(reworkBilledToCustomer, 2),
netReworkCost = Math.Round(netReworkCost, 2), netReworkCost = Math.Round(netReworkCost, 2),
@@ -4055,6 +4178,7 @@ public class JobsController : Controller
powderLines, powderLines,
laborLines, laborLines,
hasPowderData = powderLines.Count > 0, hasPowderData = powderLines.Count > 0,
hasPowderRateButNoQty = hasCoatsWithRateButNoQty && powderLines.Count == 0,
hasLaborData = laborLines.Count > 0 hasLaborData = laborLines.Count > 0
}); });
} }
@@ -454,6 +454,10 @@ public class QuoteApprovalController : Controller
CustomerEmail = quote.Customer?.Email ?? quote.ProspectEmail, CustomerEmail = quote.Customer?.Email ?? quote.ProspectEmail,
ExpirationDate = quote.ExpirationDate, ExpirationDate = quote.ExpirationDate,
ApprovalTokenExpiresAt = quote.ApprovalTokenExpiresAt, ApprovalTokenExpiresAt = quote.ApprovalTokenExpiresAt,
ItemsSubtotal = quote.ItemsSubtotal,
OvenBatchCost = quote.OvenBatchCost,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
SubTotal = quote.SubTotal, SubTotal = quote.SubTotal,
DiscountAmount = quote.DiscountAmount, DiscountAmount = quote.DiscountAmount,
HideDiscountFromCustomer = quote.HideDiscountFromCustomer, HideDiscountFromCustomer = quote.HideDiscountFromCustomer,
@@ -41,6 +41,7 @@ public class QuotesController : Controller
private readonly IJobPhotoService _jobPhotoService; private readonly IJobPhotoService _jobPhotoService;
private readonly IAiUsageLogger _usageLogger; private readonly IAiUsageLogger _usageLogger;
private readonly ICompanyLogoService _logoService; private readonly ICompanyLogoService _logoService;
private readonly IInventoryAiLookupService _aiLookupService;
public QuotesController( public QuotesController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
@@ -61,7 +62,8 @@ public class QuotesController : Controller
IWebHostEnvironment env, IWebHostEnvironment env,
IJobPhotoService jobPhotoService, IJobPhotoService jobPhotoService,
IAiUsageLogger usageLogger, IAiUsageLogger usageLogger,
ICompanyLogoService logoService) ICompanyLogoService logoService,
IInventoryAiLookupService aiLookupService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
@@ -82,6 +84,7 @@ public class QuotesController : Controller
_jobPhotoService = jobPhotoService; _jobPhotoService = jobPhotoService;
_usageLogger = usageLogger; _usageLogger = usageLogger;
_logoService = logoService; _logoService = logoService;
_aiLookupService = aiLookupService;
} }
/// <summary> /// <summary>
@@ -315,6 +318,7 @@ public class QuotesController : Controller
else else
{ {
quoteDto.ConvertedToJobId = quote.ConvertedToJobId; quoteDto.ConvertedToJobId = quote.ConvertedToJobId;
quoteDto.ConvertedToJobNumber = linkedJob.JobNumber;
} }
} }
else else
@@ -486,6 +490,8 @@ public class QuotesController : Controller
quote.ProspectCity = null; quote.ProspectCity = null;
quote.ProspectState = null; quote.ProspectState = null;
quote.ProspectZipCode = null; quote.ProspectZipCode = null;
quote.ProspectSmsConsent = false;
quote.ProspectSmsConsentedAt = null;
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -893,6 +899,8 @@ public class QuotesController : Controller
quote.QuoteNumber = await GenerateQuoteNumberAsync(); quote.QuoteNumber = await GenerateQuoteNumberAsync();
quote.PreparedById = currentUser.Id; quote.PreparedById = currentUser.Id;
quote.CompanyId = currentUser.CompanyId; quote.CompanyId = currentUser.CompanyId;
if (dto.ProspectSmsConsent)
quote.ProspectSmsConsentedAt = DateTime.UtcNow;
if (dto.SendEmailToCustomer) if (dto.SendEmailToCustomer)
{ {
@@ -1000,6 +1008,12 @@ public class QuotesController : Controller
for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++) for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
{ {
var coatDto = itemDto.Coats[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); var coat = _mapper.Map<QuoteItemCoat>(coatDto);
coat.CompanyId = currentUser.CompanyId; coat.CompanyId = currentUser.CompanyId;
@@ -1423,6 +1437,12 @@ public class QuotesController : Controller
// Update quote entity // Update quote entity
_mapper.Map(dto, quote); _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 // Set calculated pricing — snapshot at save time; never recalculate on load
quote.MaterialCosts = pricingResult.MaterialCosts; quote.MaterialCosts = pricingResult.MaterialCosts;
quote.LaborCosts = pricingResult.LaborCosts; quote.LaborCosts = pricingResult.LaborCosts;
@@ -1760,6 +1780,10 @@ public class QuotesController : Controller
for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++) for (int coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
{ {
var coatDto = itemDto.Coats[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); var coat = _mapper.Map<QuoteItemCoat>(coatDto);
coat.CompanyId = currentUser.CompanyId; coat.CompanyId = currentUser.CompanyId;
@@ -2115,9 +2139,21 @@ public class QuotesController : Controller
var customer = _mapper.Map<Customer>(dto); var customer = _mapper.Map<Customer>(dto);
customer.CompanyId = currentUser!.CompanyId; 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.Customers.AddAsync(customer);
await _unitOfWork.CompleteAsync(); 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) // Get "Converted" status (cached)
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId); var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
@@ -2135,6 +2171,8 @@ public class QuotesController : Controller
quote.ProspectCity = null; quote.ProspectCity = null;
quote.ProspectState = null; quote.ProspectState = null;
quote.ProspectZipCode = null; quote.ProspectZipCode = null;
quote.ProspectSmsConsent = false;
quote.ProspectSmsConsentedAt = null;
// Update status to converted // Update status to converted
quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId; quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId;
@@ -2283,6 +2321,8 @@ public class QuotesController : Controller
quote.ProspectCity = null; quote.ProspectCity = null;
quote.ProspectState = null; quote.ProspectState = null;
quote.ProspectZipCode = null; quote.ProspectZipCode = null;
quote.ProspectSmsConsent = false;
quote.ProspectSmsConsentedAt = null;
await _unitOfWork.Quotes.UpdateAsync(quote); await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -2650,22 +2690,25 @@ public class QuotesController : Controller
ViewBag.CompanyTaxPercent = costs.FirstOrDefault()?.TaxPercent ?? 0; 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); var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
ViewBag.InventoryCoatings = inventory ViewBag.InventoryCoatings = inventory
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating) .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 .Select(i => new
{ {
value = i.Id.ToString(), 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, coverage = i.CoverageSqFtPerLb ?? 30m,
efficiency = i.TransferEfficiency ?? 65m, efficiency = i.TransferEfficiency ?? 65m,
unitOfMeasure = i.UnitOfMeasure ?? "lbs", unitOfMeasure = i.UnitOfMeasure ?? "lbs",
categoryName = i.InventoryCategory.DisplayName, categoryName = i.InventoryCategory!.DisplayName,
costPerLb = i.UnitCost, costPerLb = i.UnitCost,
colorName = i.ColorName ?? i.Name, colorName = i.ColorName ?? i.Name,
colorCode = i.ColorCode ?? "" colorCode = i.ColorCode ?? "",
isIncoming = i.IsIncoming
}).ToList(); }).ToList();
// Vendors // Vendors
@@ -3021,18 +3064,20 @@ public class QuotesController : Controller
Description = quote.Description ?? $"Job from Quote {quote.QuoteNumber}", Description = quote.Description ?? $"Job from Quote {quote.QuoteNumber}",
JobStatusId = approvedStatus?.Id ?? 1, JobStatusId = approvedStatus?.Id ?? 1,
JobPriorityId = selectedPriority?.Id ?? 1, JobPriorityId = selectedPriority?.Id ?? 1,
QuotedPrice = quote.Total, QuotedPrice = quote.Total,
FinalPrice = quote.Total, FinalPrice = quote.Total,
CustomerPO = quote.CustomerPO, ShopSuppliesAmount = quote.ShopSuppliesAmount,
InternalNotes = quote.Notes, // Copy internal notes from quote ShopSuppliesPercent = quote.ShopSuppliesPercent,
IsCustomerApproved = true, CustomerPO = quote.CustomerPO,
IsRushJob = quote.IsRushJob, InternalNotes = quote.Notes, // Copy internal notes from quote
DiscountType = quote.DiscountType, IsCustomerApproved = true,
DiscountValue = quote.DiscountValue, IsRushJob = quote.IsRushJob,
DiscountReason = quote.DiscountReason, DiscountType = quote.DiscountType,
CompanyId = quote.CompanyId, DiscountValue = quote.DiscountValue,
CreatedAt = DateTime.UtcNow, DiscountReason = quote.DiscountReason,
QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow,
QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt
}; };
await _unitOfWork.Jobs.AddAsync(job); await _unitOfWork.Jobs.AddAsync(job);
@@ -3164,9 +3209,14 @@ public class QuotesController : Controller
uniquePrepServiceIds.Count, job.JobNumber); uniquePrepServiceIds.Count, job.JobNumber);
} }
// Update quote to track the conversion // Update quote to track the conversion and mark it as converted
quote.ConvertedToJobId = job.Id; quote.ConvertedToJobId = job.Id;
quote.ConvertedDate = DateTime.UtcNow; quote.ConvertedDate = DateTime.UtcNow;
var companyIdForStatus = quote.CompanyId;
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyIdForStatus);
var convertedQuoteStatus = quoteStatuses.FirstOrDefault(s => s.StatusCode == "CONVERTED");
if (convertedQuoteStatus != null)
quote.QuoteStatusId = convertedQuoteStatus.Id;
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
// The interceptor just bumped quote.UpdatedAt as part of the ConvertedToJobId write. // The interceptor just bumped quote.UpdatedAt as part of the ConvertedToJobId write.
@@ -3270,7 +3320,7 @@ public class QuotesController : Controller
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> ResendQuote(int id) public async Task<IActionResult> ResendQuote(int id, string? overrideEmail = null)
{ {
try try
{ {
@@ -3278,10 +3328,12 @@ public class QuotesController : Controller
if (quote == null) if (quote == null)
return Json(new { success = false, message = "Quote not found." }); return Json(new { success = false, message = "Quote not found." });
var trimmedOverride = overrideEmail?.Trim();
// Determine recipient for feedback message // Determine recipient for feedback message
string? recipientEmail = quote.CustomerId.HasValue string? recipientEmail = !string.IsNullOrWhiteSpace(trimmedOverride)
? quote.Customer?.Email ? trimmedOverride
: quote.ProspectEmail; : (quote.CustomerId.HasValue ? quote.Customer?.Email : quote.ProspectEmail);
string recipientName = quote.CustomerId.HasValue && quote.Customer != null string recipientName = quote.CustomerId.HasValue && quote.Customer != null
? (!string.IsNullOrWhiteSpace(quote.Customer.CompanyName) ? (!string.IsNullOrWhiteSpace(quote.Customer.CompanyName)
@@ -3318,7 +3370,7 @@ public class QuotesController : Controller
await _unitOfWork.Quotes.UpdateAsync(quote); await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync(); 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 // Check the most recent log entry to get actual send status
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id); var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id);
@@ -3737,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> /// <summary>
/// After pricing is determined for an AI item, update the prediction record to flag whether /// 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. /// 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 string? CustomerEmail { get; set; }
public DateTime? ExpirationDate { get; set; } public DateTime? ExpirationDate { get; set; }
public DateTime? ApprovalTokenExpiresAt { 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 SubTotal { get; set; }
public decimal DiscountAmount { get; set; } public decimal DiscountAmount { get; set; }
public bool HideDiscountFromCustomer { get; set; } public bool HideDiscountFromCustomer { get; set; }
@@ -154,7 +154,7 @@
(function () { (function () {
// SubType enum values → AccountType enum values (mirrors server-side mapping) // SubType enum values → AccountType enum values (mirrors server-side mapping)
const subTypeToAccountType = { 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 10: 2, 11: 2, 12: 2, 13: 2, // Liabilities
20: 3, 21: 3, // Equity 20: 3, 21: 3, // Equity
30: 4, 31: 4, 32: 4, // Revenue 30: 4, 31: 4, 32: 4, // Revenue
@@ -144,7 +144,7 @@
<script> <script>
// Auto-set AccountType when SubType is changed // Auto-set AccountType when SubType is changed
const subTypeToAccountType = { 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 10: 2, 11: 2, 12: 2, 13: 2, // Liability
20: 3, 21: 3, // Equity 20: 3, 21: 3, // Equity
30: 4, 31: 4, 32: 4, // Revenue 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> <span class="badge bg-secondary ms-1" title="System account — cannot be deleted">sys</span>
} }
</td> </td>
<td><span class="text-muted small">@acct.AccountSubType</span></td> <td><span class="text-muted small">@acct.AccountSubType.ToDisplayName()</span></td>
<td> <td>
@if (!string.IsNullOrEmpty(acct.ParentAccountName)) @if (!string.IsNullOrEmpty(acct.ParentAccountName))
{ {
@@ -29,10 +29,11 @@
_ => "bi-journal" _ => "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) // Derive from AccountSubType (more reliable than AccountType which users can misconfigure)
bool normalDebitBalance = bool normalDebitBalance =
Model.AccountSubType == AccountSubType.Cash ||
Model.AccountSubType == AccountSubType.Checking || Model.AccountSubType == AccountSubType.Checking ||
Model.AccountSubType == AccountSubType.Savings || Model.AccountSubType == AccountSubType.Savings ||
Model.AccountSubType == AccountSubType.AccountsReceivable || Model.AccountSubType == AccountSubType.AccountsReceivable ||
@@ -71,7 +72,7 @@
<div> <div>
<p class="text-muted mb-0"> <p class="text-muted mb-0">
<span class="badge bg-@typeColor bg-opacity-75 me-1">@typeLabel</span> <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> </p>
</div> </div>
<div class="ms-auto"> <div class="ms-auto">
@@ -74,7 +74,7 @@
</div> </div>
<div class="col-md-6"> <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> <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> <span asp-validation-for="Email" class="text-danger"></span>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
@@ -87,6 +87,14 @@
<input asp-for="MobilePhone" type="tel" class="form-control" placeholder="(555) 123-4567" /> <input asp-for="MobilePhone" type="tel" class="form-control" placeholder="(555) 123-4567" />
<span asp-validation-for="MobilePhone" class="text-danger"></span> <span asp-validation-for="MobilePhone" class="text-danger"></span>
</div> </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>
</div> </div>
@@ -372,4 +380,5 @@
@section Scripts { @section Scripts {
<partial name="_ValidationScriptsPartial" /> <partial name="_ValidationScriptsPartial" />
<script src="~/js/customer-billing-email.js"></script>
} }
@@ -97,11 +97,37 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="text-muted small mb-1">Email</label> <label class="text-muted small mb-1">Email</label>
<p class="mb-0"> <p class="mb-0">
<a href="mailto:@Model.Email" class="text-decoration-none"> @if (!string.IsNullOrEmpty(Model.Email))
<i class="bi bi-envelope me-1"></i>@Model.Email {
</a> <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> </p>
</div> </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"> <div class="col-md-6">
<label class="text-muted small mb-1">Phone</label> <label class="text-muted small mb-1">Phone</label>
<p class="mb-0"> <p class="mb-0">
@@ -78,7 +78,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="Email" class="form-label">Email</label> <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> <span asp-validation-for="Email" class="text-danger"></span>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
@@ -91,6 +91,14 @@
<input asp-for="MobilePhone" type="tel" class="form-control" placeholder="(555) 123-4567" /> <input asp-for="MobilePhone" type="tel" class="form-control" placeholder="(555) 123-4567" />
<span asp-validation-for="MobilePhone" class="text-danger"></span> <span asp-validation-for="MobilePhone" class="text-danger"></span>
</div> </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>
</div> </div>
@@ -436,4 +444,5 @@
@section Scripts { @section Scripts {
<partial name="_ValidationScriptsPartial" /> <partial name="_ValidationScriptsPartial" />
<script src="~/js/customer-billing-email.js"></script>
} }
@@ -386,7 +386,7 @@
</div> </div>
</div> </div>
<!-- Notes --> <!-- Notes & Status -->
<div class="mb-4"> <div class="mb-4">
<h5 class="border-bottom pb-2 mb-3"> <h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-journal-text me-2 text-primary"></i>Notes <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> <textarea asp-for="Notes" class="form-control" rows="3"></textarea>
<span asp-validation-for="Notes" class="text-danger"></span> <span asp-validation-for="Notes" class="text-danger"></span>
</div> </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>
</div> </div>
@@ -398,6 +398,17 @@
<textarea asp-for="Notes" class="form-control" rows="3"></textarea> <textarea asp-for="Notes" class="form-control" rows="3"></textarea>
<span asp-validation-for="Notes" class="text-danger"></span> <span asp-validation-for="Notes" class="text-danger"></span>
</div> </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>
</div> </div>
@@ -275,7 +275,13 @@
<span class="fw-semibold">@((item.QuantityOnHand * item.UnitCost).ToString("C"))</span> <span class="fw-semibold">@((item.QuantityOnHand * item.UnitCost).ToString("C"))</span>
</td> </td>
<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"> <span class="badge bg-success bg-opacity-10 text-success">
<i class="bi bi-check-circle me-1"></i>Active <i class="bi bi-check-circle me-1"></i>Active
@@ -175,11 +175,11 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Payment Terms" 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> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </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> </div>
</div> </div>
@@ -446,6 +446,7 @@
</div> </div>
@section Scripts { @section Scripts {
<script src="~/js/invoice-due-date.js"></script>
<script> <script>
let itemCount = @Model.InvoiceItems.Count; let itemCount = @Model.InvoiceItems.Count;
const merchandiseItems = @Html.Raw(ViewBag.MerchandiseItems ?? "[]"); const merchandiseItems = @Html.Raw(ViewBag.MerchandiseItems ?? "[]");
@@ -10,6 +10,7 @@
var statusDisplay = InvoicesController.GetStatusDisplay(Model.Status); var statusDisplay = InvoicesController.GetStatusDisplay(Model.Status);
var isDraft = Model.Status == InvoiceStatus.Draft; var isDraft = Model.Status == InvoiceStatus.Draft;
var isVoided = Model.Status == InvoiceStatus.Voided || Model.Status == InvoiceStatus.WrittenOff; 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 canPay = !isVoided && Model.BalanceDue > 0;
var canResend = !isDraft && !isVoided && Model.Status != InvoiceStatus.Paid; var canResend = !isDraft && !isVoided && Model.Status != InvoiceStatus.Paid;
var hasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail); var hasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail);
@@ -30,12 +31,16 @@
<!-- Header --> <!-- Header -->
<div class="d-flex justify-content-end gap-2 mb-4"> <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"> <a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
<i class="bi bi-pencil me-2"></i>Edit <i class="bi bi-pencil me-2"></i>Edit
</a> </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"> <a asp-action="DownloadPdf" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-file-pdf me-2"></i>PDF <i class="bi bi-file-pdf me-2"></i>PDF
</a> </a>
@@ -64,7 +69,7 @@
<div class="alert alert-warning d-flex align-items-center gap-2 mb-4"> <div class="alert alert-warning d-flex align-items-center gap-2 mb-4">
<i class="bi bi-envelope-slash fs-5"></i> <i class="bi bi-envelope-slash fs-5"></i>
<span> <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>. <a asp-controller="Customers" asp-action="Edit" asp-route-id="@Model.CustomerId" class="alert-link">Add one in customer settings</a>.
</span> </span>
</div> </div>
@@ -566,31 +571,37 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="d-grid gap-2"> <div class="d-grid gap-2">
@if (isDraft) @if (canEdit)
{ {
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-primary"> <a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-primary">
<i class="bi bi-pencil me-2"></i>Edit Invoice <i class="bi bi-pencil me-2"></i>Edit Invoice
</a> </a>
@if (hasEmail) <form id="sendInvoiceForm" asp-action="Send" asp-route-id="@Model.Id" method="post">
{ @Html.AntiForgeryToken()
<form id="sendInvoiceForm" asp-action="Send" asp-route-id="@Model.Id" method="post"> <input type="hidden" name="overrideEmail" id="sendInvoiceOverrideEmail" value="" />
@Html.AntiForgeryToken() @if (emailOptedOut)
@if (emailOptedOut) {
{ <button type="button" class="btn btn-primary w-100" disabled
<button type="button" class="btn btn-primary w-100" disabled title="Email notifications are turned off for this customer">
title="Email notifications are turned off for this customer"> <i class="bi bi-send me-2"></i>Send Invoice
<i class="bi bi-send me-2"></i>Send Invoice </button>
</button> }
} else if (hasEmail)
else {
{ <button type="button" class="btn btn-primary w-100"
<button type="button" class="btn btn-primary w-100" data-bs-toggle="modal" data-bs-target="#sendInvoiceModal">
data-bs-toggle="modal" data-bs-target="#sendInvoiceModal"> <i class="bi bi-send me-2"></i>Send Invoice
<i class="bi bi-send me-2"></i>Send Invoice </button>
</button> }
} else
</form> {
} <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) @if (canPay)
{ {
@@ -598,12 +609,23 @@
<i class="bi bi-cash me-2"></i>Record Payment <i class="bi bi-cash me-2"></i>Record Payment
</button> </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"> <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 <i class="bi bi-file-pdf me-2"></i>Download PDF
</a> </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 <button type="button" class="btn btn-outline-primary" disabled
title="Email notifications are turned off for this customer"> title="Email notifications are turned off for this customer">
@@ -978,6 +1000,34 @@
</div> </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) --> <!-- Re-send Invoice Modal (AJAX) -->
<div class="modal fade" id="resendInvoiceModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="resendInvoiceModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
@@ -1299,7 +1349,27 @@
}, 400); }, 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('resendInvoiceSending').classList.remove('d-none');
document.getElementById('resendInvoiceResult').classList.add('d-none'); document.getElementById('resendInvoiceResult').classList.add('d-none');
document.getElementById('resendInvoiceFooter').classList.add('d-none'); document.getElementById('resendInvoiceFooter').classList.add('d-none');
@@ -1309,8 +1379,10 @@
modal.show(); modal.show();
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''; 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', method: 'POST',
headers: { 'RequestVerificationToken': token, 'X-Requested-With': 'XMLHttpRequest' } headers: { 'RequestVerificationToken': token, 'X-Requested-With': 'XMLHttpRequest' }
}) })
@@ -7,6 +7,7 @@
var invoiceId = (int)(ViewBag.InvoiceId ?? 0); var invoiceId = (int)(ViewBag.InvoiceId ?? 0);
var jobNumber = ViewBag.JobNumber as string; var jobNumber = ViewBag.JobNumber as string;
var customerName = ViewBag.CustomerName 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"> <div class="d-flex justify-content-between align-items-center mb-4">
@@ -37,7 +38,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Invoice Details" 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> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -64,7 +65,7 @@
<div class="row g-3 mt-1"> <div class="row g-3 mt-1">
<div class="col-md-12"> <div class="col-md-12">
<label asp-for="Terms" class="form-label fw-semibold">Payment Terms</label> <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> </div>
</div> </div>
@@ -234,6 +235,15 @@
<!-- Actions --> <!-- Actions -->
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-body d-grid gap-2"> <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"> <button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-2"></i>Save Changes <i class="bi bi-check-circle me-2"></i>Save Changes
</button> </button>
@@ -242,9 +252,10 @@
</a> </a>
</div> </div>
<div class="card-footer border-0 pt-0"> <div class="card-footer border-0 pt-0">
<div class="alert alert-warning mb-0 small py-2"> <div class="alert alert-info mb-0 small py-2">
<i class="bi bi-exclamation-triangle me-1"></i> <i class="bi bi-info-circle me-1"></i>
Only <strong>Draft</strong> invoices can be edited. Send the invoice to lock it. <strong>Draft, Sent,</strong> and <strong>Overdue</strong> invoices can be edited.
Paid invoices are locked.
</div> </div>
</div> </div>
</div> </div>
+266 -14
View File
@@ -1315,6 +1315,7 @@
} }
@{ @{
var panelInvoiceId = ViewBag.JobInvoiceId as int?; var panelInvoiceId = ViewBag.JobInvoiceId as int?;
var voidedInvoices = ViewBag.JobVoidedInvoices as IEnumerable<dynamic> ?? [];
} }
@if (panelInvoiceId.HasValue) @if (panelInvoiceId.HasValue)
{ {
@@ -1330,6 +1331,13 @@
<i class="bi bi-receipt me-2"></i>Create Invoice <i class="bi bi-receipt me-2"></i>Create Invoice
</a> </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"> <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 <i class="bi bi-printer me-2"></i>Print Work Order
</a> </a>
@@ -1360,17 +1368,20 @@
</div> </div>
</div> </div>
<!-- Pricing Summary --> <!-- Pricing Summary (internal — d-print-none) -->
<div class="card border-0 shadow-sm mb-4"> @{
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"> <div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"> <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> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
@if (!string.IsNullOrWhiteSpace(Model.OvenLabel)) @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> <i class="bi bi-thermometer-half text-warning me-2"></i>
<div> <div>
<small class="text-muted d-block">Oven</small> <small class="text-muted d-block">Oven</small>
@@ -1378,14 +1389,253 @@
</div> </div>
</div> </div>
} }
<div class="mb-3">
<label class="text-muted small mb-1">Quoted Price</label> @if (jobPb != null)
<p class="mb-0 fw-semibold">@Model.QuotedPrice.ToString("C")</p> {
</div> <div class="d-flex justify-content-between mb-2">
<div> <span>Items Subtotal:</span>
<label class="text-muted small mb-1">Final Price</label> <strong>@jobPb.ItemsSubtotal.ToString("C")</strong>
<h3 class="mb-0 text-primary">@Model.FinalPrice.ToString("C")</h3> </div>
</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>
</div> </div>
@@ -2584,7 +2834,8 @@
// Notes // Notes
const 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.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.'); 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(''); document.getElementById('costingNotes').innerHTML = notes.map(n => `<div class="text-muted">${n}</div>`).join('');
@@ -2618,6 +2869,7 @@
<script> <script>
const timeTracking = (() => { const timeTracking = (() => {
const jid = @Model.Id; const jid = @Model.Id;
const currentUserId = '@(ViewBag.CurrentUserId ?? "")';
const modal = new bootstrap.Modal(document.getElementById('timeEntryModal')); const modal = new bootstrap.Modal(document.getElementById('timeEntryModal'));
let entries = []; let entries = [];
@@ -2673,7 +2925,7 @@
function openAdd() { function openAdd() {
document.getElementById('timeEntryModalTitle').textContent = 'Log Time'; document.getElementById('timeEntryModalTitle').textContent = 'Log Time';
document.getElementById('teEntryId').value = '0'; 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('teWorkDate').value = new Date().toISOString().slice(0, 10);
document.getElementById('teHoursWorked').value = ''; document.getElementById('teHoursWorked').value = '';
document.getElementById('teStage').value = ''; document.getElementById('teStage').value = '';
+1 -1
View File
@@ -285,7 +285,7 @@
</label> </label>
</div> </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"> <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 <i class="bi bi-x-circle me-1"></i>Cancel
</a> </a>
@@ -1,6 +1,9 @@
@model PowderCoating.Application.DTOs.Job.JobDto @model PowderCoating.Application.DTOs.Job.JobDto
@{ @{
var emailDefault = ViewBag.EmailDefaultOnComplete == true; 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 fade" id="completeJobModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg"> <div class="modal-dialog modal-dialog-centered modal-lg">
@@ -76,12 +79,27 @@
<small class="text-muted">@((coat.PowderToOrder ?? 0).ToString("0.##"))</small> <small class="text-muted">@((coat.PowderToOrder ?? 0).ToString("0.##"))</small>
</td> </td>
<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="hidden" name="CoatUsages[@coatIndex].JobItemCoatId" value="@coat.Id" />
<input type="number" <input type="number"
class="form-control form-control-sm" class="form-control form-control-sm"
name="CoatUsages[@coatIndex].ActualPowderUsedLbs" name="CoatUsages[@coatIndex].ActualPowderUsedLbs"
step="0.01" min="0" placeholder="0.00" step="0.01" min="0" placeholder="0.00"
value="@(preFilledLbs > 0 ? preFilledLbs.ToString("0.##") : "")"
style="max-width: 120px;"> 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> </td>
</tr> </tr>
coatIndex++; coatIndex++;
@@ -104,7 +122,7 @@
</div> </div>
<div class="alert alert-info mb-0"> <div class="alert alert-info mb-0">
<i class="bi bi-info-circle me-2"></i> <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>
</div> </div>
} }
@@ -90,9 +90,30 @@
<div class="row justify-content-end"> <div class="row justify-content-end">
<div class="col-sm-6 col-md-5"> <div class="col-sm-6 col-md-5">
<div class="d-flex justify-content-between mb-1"> <div class="d-flex justify-content-between mb-1">
<span class="text-muted">Subtotal</span> <span class="text-muted">Items Subtotal</span>
<span>@Model.SubTotal.ToString("C")</span> <span>@Model.ItemsSubtotal.ToString("C")</span>
</div> </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) @if (Model.DiscountAmount > 0 && !Model.HideDiscountFromCustomer)
{ {
<div class="d-flex justify-content-between mb-1 text-success"> <div class="d-flex justify-content-between mb-1 text-success">
@@ -207,6 +207,47 @@
</div> </div>
</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 --> <!-- Form Actions -->
<div class="mb-4"> <div class="mb-4">
<button type="submit" class="btn btn-success btn-lg"> <button type="submit" class="btn btn-success btn-lg">
@@ -104,6 +104,19 @@
<span asp-validation-for="ProspectPhone" class="text-danger"></span> <span asp-validation-for="ProspectPhone" class="text-danger"></span>
</div> </div>
</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="row mt-2 quote-advanced-only">
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="ProspectAddress" class="form-label"></label> <label asp-for="ProspectAddress" class="form-label"></label>
@@ -764,8 +777,23 @@
if (el) el.removeAttribute('required'); 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 // Discount type toggle
function onDiscountTypeChange() { function onDiscountTypeChange() {
const type = document.getElementById('discountTypeSelect').value; const type = document.getElementById('discountTypeSelect').value;
@@ -92,6 +92,21 @@
<p><strong>Contact Name:</strong> @(Model.ProspectContactName ?? "-")</p> <p><strong>Contact Name:</strong> @(Model.ProspectContactName ?? "-")</p>
<p><strong>Email:</strong> @(Model.ProspectEmail ?? "-")</p> <p><strong>Email:</strong> @(Model.ProspectEmail ?? "-")</p>
<p><strong>Phone:</strong> @(Model.ProspectPhone ?? "-")</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>
<div class="col-md-6"> <div class="col-md-6">
<p><strong>Address:</strong> @(Model.ProspectAddress ?? "-")</p> <p><strong>Address:</strong> @(Model.ProspectAddress ?? "-")</p>
@@ -1528,6 +1543,8 @@
var detHasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail); var detHasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail);
var detHasMobile = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone); var detHasMobile = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone);
var detHasSmsConsent = Model.CustomerNotifyBySms && detHasMobile; 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") @if (Model.StatusCode != "APPROVED" && Model.StatusCode != "CONVERTED")
{ {
@@ -1549,28 +1566,49 @@
</button> </button>
</form> </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)"> <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 <i class="bi bi-envelope-arrow-up me-1"></i>Send Quote via Email
</button> </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)"> <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 <i class="bi bi-chat-dots me-1"></i>Send Quote via SMS
</button> </button>
} }
@if (!detHasMobile && !detHasEmail) @if (!detHasMobile && !detHasEmail && !detProspectHasPhone && string.IsNullOrWhiteSpace(Model.ProspectEmail))
{ {
<div class="alert alert-warning alert-permanent py-1 px-2 small"> <div class="alert alert-warning alert-permanent py-1 px-2 small">
<i class="bi bi-exclamation-triangle me-1"></i>No email or phone — update the customer record to send this quote. <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.
</div> </div>
} }
@if (detHasMobile && !detHasSmsConsent) @if (detHasMobile && !detHasSmsConsent)
{ {
<div class="text-muted small"><i class="bi bi-phone-slash me-1"></i>SMS consent required to send via text.</div> <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) @if (!Model.ConvertedToJobId.HasValue)
{ {
<form asp-action="ConvertToJob" asp-route-id="@Model.Id" method="post" class="d-inline" id="createJobForm"> <form asp-action="ConvertToJob" asp-route-id="@Model.Id" method="post" class="d-inline" id="createJobForm">
@@ -1590,7 +1628,7 @@
@if (Model.ConvertedToJobId.HasValue) @if (Model.ConvertedToJobId.HasValue)
{ {
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@Model.ConvertedToJobId" class="btn btn-outline-info"> <a asp-controller="Jobs" asp-action="Details" asp-route-id="@Model.ConvertedToJobId" class="btn btn-outline-info">
<i class="bi bi-clipboard-check me-1"></i>View Job <i class="bi bi-clipboard-check me-1"></i>View Job @Model.ConvertedToJobNumber
</a> </a>
} }
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" class="btn btn-primary"> <a asp-action="DownloadPdf" asp-route-id="@Model.Id" class="btn btn-primary">
@@ -2103,6 +2141,30 @@
</style> </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 --> <!-- Send Quote via SMS Modal -->
<div class="modal fade" id="sendQuoteSmsModal" tabindex="-1" aria-labelledby="sendQuoteSmsModalLabel" aria-hidden="true"> <div class="modal fade" id="sendQuoteSmsModal" tabindex="-1" aria-labelledby="sendQuoteSmsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-sm"> <div class="modal-dialog modal-sm">
@@ -2201,7 +2263,20 @@
@section Scripts { @section Scripts {
<script src="~/js/customer-change.js" asp-append-version="true"></script> <script src="~/js/customer-change.js" asp-append-version="true"></script>
<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 // Reset modal state
document.getElementById('sendQuoteSending').classList.remove('d-none'); document.getElementById('sendQuoteSending').classList.remove('d-none');
document.getElementById('sendQuoteResult').classList.add('d-none'); document.getElementById('sendQuoteResult').classList.add('d-none');
@@ -2212,8 +2287,10 @@
modal.show(); modal.show();
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''; 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', method: 'POST',
headers: { 'RequestVerificationToken': token, 'X-Requested-With': 'XMLHttpRequest' } headers: { 'RequestVerificationToken': token, 'X-Requested-With': 'XMLHttpRequest' }
}) })
+26 -1
View File
@@ -52,10 +52,23 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="ProspectPhone" class="form-label">Phone <span class="text-danger">*</span></label> <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> <span asp-validation-for="ProspectPhone" class="text-danger"></span>
</div> </div>
</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="row mt-2">
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="ProspectAddress" class="form-label"></label> <label asp-for="ProspectAddress" class="form-label"></label>
@@ -683,8 +696,20 @@
if (custEl) new TomSelect(custEl, { placeholder: '-- Select Customer --', openOnFocus: true, maxOptions: false, if (custEl) new TomSelect(custEl, { placeholder: '-- Select Customer --', openOnFocus: true, maxOptions: false,
onChange: function(value) { onQuoteCustomerChanged({ value: value }); } 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) { function onQuoteCustomerChanged(select) {
const meta = JSON.parse(document.getElementById('quoteMetaData').textContent); const meta = JSON.parse(document.getElementById('quoteMetaData').textContent);
const optOutIds = new Set(meta.emailOptOutCustomerIds || []); 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 }); 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.addEventListener('click', e => {
document.querySelectorAll('[id^="coat_powder_wrapper_"]').forEach(wrapper => { document.querySelectorAll('[id^="coat_powder_wrapper_"]').forEach(wrapper => {
if (!wrapper.contains(e.target)) { if (!wrapper.contains(e.target)) {
@@ -92,6 +92,12 @@ document.addEventListener('DOMContentLoaded', () => {
powderComboClose(parseInt(idx)); 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 || ''}"> <input type="number" class="form-control" id="coat_costPerLb_${i}" min="0" step="0.01" placeholder="auto" value="${coat.powderCostPerLb || ''}">
</div> </div>
</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> </div>
<!-- Custom powder --> <!-- Custom powder -->
<div id="coat_custom_section_${i}" class="row g-2" style="display:${coat.powderType === 'custom' ? 'flex' : 'none'}"> <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"> <div class="col-sm-6">
<label class="form-label form-label-sm">Color Name</label> <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"> <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 || ''}"> <input type="number" class="form-control" id="coat_custom_costPerLb_${i}" min="0" step="0.01" placeholder="0.00" value="${coat.powderCostPerLb || ''}">
</div> </div>
</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="col-12">
<div class="alert alert-warning py-2 mb-0"> <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> <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}`); const el = document.getElementById(`coat_custom_orderQty_${i}`);
if (el) el.value = coat.powderToOrder; 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) { function removeCoatRow(i) {
@@ -1769,9 +1831,11 @@ function powderComboInput(i) {
const q = document.getElementById(`coat_powder_search_${i}`)?.value?.toLowerCase() || ''; const q = document.getElementById(`coat_powder_search_${i}`)?.value?.toLowerCase() || '';
powderComboRender(i, q); powderComboRender(i, q);
powderComboShow(i); 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}`); const hidden = document.getElementById(`coat_inventoryItemId_${i}`);
if (hidden) hidden.value = ''; if (hidden) hidden.value = '';
const incomingSection = document.getElementById(`coat_incoming_section_${i}`);
if (incomingSection) incomingSection.style.display = 'none';
} }
function powderComboOpen(i) { function powderComboOpen(i) {
@@ -1798,19 +1862,30 @@ function powderComboRender(i, query) {
? powderData.filter(p => p.text.toLowerCase().includes(query)) ? powderData.filter(p => p.text.toLowerCase().includes(query))
: powderData; : powderData;
if (filtered.length === 0) { 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; return;
} }
dd.innerHTML = filtered.map(p => 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;" 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-val="${escHtml(String(p.value))}"
data-txt="${escHtml(p.text)}" data-txt="${escHtml(p.text)}"
onmousedown="event.preventDefault(); powderComboSelect(${i}, this.dataset.val, this.dataset.txt)" onmousedown="event.preventDefault(); powderComboSelect(${i}, this.dataset.val, this.dataset.txt)"
onmouseenter="this.style.background=document.documentElement.getAttribute('data-bs-theme')==='dark'?'#2c3a5a':'#f0f4ff'" onmouseenter="this.style.background=document.documentElement.getAttribute('data-bs-theme')==='dark'?'#2c3a5a':'#f0f4ff'"
onmouseleave="this.classList.contains('pw-active')?null:this.style.background=''"> onmouseleave="this.classList.contains('pw-active')?null:this.style.background=''">
${escHtml(p.text)} ${escHtml(displayText)}${badge}
</div>` </div>`;
).join(''); }).join('');
} }
function powderComboShow(i) { 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) { function onPowderSelected(i) {
const sel = document.getElementById(`coat_inventoryItemId_${i}`); const sel = document.getElementById(`coat_inventoryItemId_${i}`);
if (!sel || !sel.value) return; if (!sel || !sel.value) return;
@@ -1880,6 +2115,11 @@ function onPowderSelected(i) {
if (covEl) covEl.value = powder.coverage; if (covEl) covEl.value = powder.coverage;
if (effEl) effEl.value = powder.efficiency; if (effEl) effEl.value = powder.efficiency;
if (costEl && powder.costPerLb) costEl.value = parseFloat(powder.costPerLb).toFixed(2); 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); updatePowderNeeded(i);
} }
@@ -1899,9 +2139,14 @@ function updatePowderNeeded(i) {
const valEl = document.getElementById(`coat_powderNeededVal_${i}`); const valEl = document.getElementById(`coat_powderNeededVal_${i}`);
if (valEl) valEl.textContent = lbs.toFixed(2) + ' lbs'; 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}`); const calcQtyEl = document.getElementById(`coat_custom_calcQty_${i}`);
if (calcQtyEl) calcQtyEl.textContent = lbs.toFixed(2) + ' lbs'; 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() { function updateAllPowderNeeded() {
@@ -2238,15 +2483,20 @@ function collectStep3() {
if (!isCustom) { if (!isCustom) {
const invId = document.getElementById(`coat_inventoryItemId_${i}`)?.value; const invId = document.getElementById(`coat_inventoryItemId_${i}`)?.value;
coat.inventoryItemId = invId ? parseInt(invId) : null; 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) { if (coat.inventoryItemId) {
const powder = powderData.find(p => p.value === String(invId)); 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.coverageSqFtPerLb = parseFloat(document.getElementById(`coat_coverage_${i}`)?.value) || 30;
coat.transferEfficiency = parseFloat(document.getElementById(`coat_efficiency_${i}`)?.value) || 65; coat.transferEfficiency = parseFloat(document.getElementById(`coat_efficiency_${i}`)?.value) || 65;
const costEl = document.getElementById(`coat_costPerLb_${i}`)?.value; const costEl = document.getElementById(`coat_costPerLb_${i}`)?.value;
coat.powderCostPerLb = costEl ? parseFloat(costEl) : null; coat.powderCostPerLb = costEl ? parseFloat(costEl) : null;
coat.isIncoming = isIncomingCoat;
} else { } else {
coat.colorName = document.getElementById(`coat_colorName_${i}`)?.value?.trim() || null; coat.colorName = document.getElementById(`coat_colorName_${i}`)?.value?.trim() || null;
coat.colorCode = document.getElementById(`coat_colorCode_${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; coat.transferEfficiency = parseFloat(document.getElementById(`coat_custom_efficiency_${i}`)?.value) || 65;
const costEl = document.getElementById(`coat_custom_costPerLb_${i}`)?.value; const costEl = document.getElementById(`coat_custom_costPerLb_${i}`)?.value;
coat.powderCostPerLb = costEl ? parseFloat(costEl) : null; 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) { if (isCustom) {
const orderQtyVal = document.getElementById(`coat_custom_orderQty_${i}`)?.value; const orderQtyVal = document.getElementById(`coat_custom_orderQty_${i}`)?.value;
coat.powderToOrder = orderQtyVal ? parseFloat(orderQtyVal) : null; 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 { } else {
const sqft = parseFloat(wz.data.surfaceAreaSqFt) || 0; const sqft = parseFloat(wz.data.surfaceAreaSqFt) || 0;
const qty = parseInt(wz.data.quantity) || 1; const qty = parseInt(wz.data.quantity) || 1;
@@ -2295,7 +2552,10 @@ function preFillStep2() {
if (wz.itemType === 'product' && d.catalogItemId) { if (wz.itemType === 'product' && d.catalogItemId) {
const listItem = document.querySelector(`#catalogListbox [data-value="${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') { if (wz.itemType === 'calculated') {
@@ -2534,6 +2794,8 @@ function writeHiddenFields() {
if (coat.powderToOrder) fields.push(h(cp + '.PowderToOrder', coat.powderToOrder)); if (coat.powderToOrder) fields.push(h(cp + '.PowderToOrder', coat.powderToOrder));
if (coat.notes) fields.push(h(cp + '.Notes', coat.notes)); if (coat.notes) fields.push(h(cp + '.Notes', coat.notes));
fields.push(h(cp + '.NoExtraLayerCharge', coat.noExtraLayerCharge ? 'true' : 'false')); 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'));
}); });
}); });