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>
This commit is contained in:
@@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Generated
+9531
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user