Demo data realism + invoice resend via SMS on any status
Seed data fixes: - Fix EF interceptor: no longer overwrites explicitly-set CreatedAt on Added entities — root cause of all "same month" chart issues - Customer seeder: generates 15 customers/month from Jan → current month; keeps 10 commercial anchors in deterministic order for job seeder index map - Invoice seeder: historical range bumped from 2→8 paid invoices/month so P&L shows consistent profit (~$5,200 collected vs ~$4,200 monthly expenses) - Month -1 bumped to 7 paid invoices to stay above expenses - Jobs: set UpdatedAt to historical event date so analytics don't need null fallback - Analytics (ReportsController): use CompletedDate ?? UpdatedAt ?? CreatedAt for revenue chart grouping; fixes empty Revenue Trend charts on Overview/Revenue tabs - SeedDataService: inject IAccountBalanceService; auto-recalculate account balances after seeding; patch checking/savings opening balances unconditionally on reset - Customer list: sort by CompanyName ?? ContactLastName so individuals and commercial accounts interleave instead of appearing as two blocks Invoice resend: - ResendInvoice action now accepts sendEmail + sendSms parameters; SMS-only resend no longer requires an email address on file - Ensures PublicViewToken exists before SMS so the view link is always valid - canResend in Details view now allows Paid invoices (removed != Paid guard) - Resend button shows channel-choice modal when customer has both email + SMS, direct SMS button when SMS only, or email button when email only - New #resendChannelModal mirrors the Send channel modal but posts to ResendInvoice - resendInvoice() JS updated to pass sendEmail/sendSms query params Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -91,7 +91,9 @@ public class CustomersController : Controller
|
||||
// Build orderBy function
|
||||
Func<IQueryable<Customer>, IOrderedQueryable<Customer>> orderBy = gridRequest.SortColumn switch
|
||||
{
|
||||
"CompanyName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CompanyName) : q.OrderByDescending(c => c.CompanyName),
|
||||
"CompanyName" => q => gridRequest.SortDirection == "asc"
|
||||
? q.OrderBy(c => c.CompanyName ?? c.ContactLastName)
|
||||
: q.OrderByDescending(c => c.CompanyName ?? c.ContactLastName),
|
||||
"ContactName" => q => gridRequest.SortDirection == "asc"
|
||||
? q.OrderBy(c => c.ContactFirstName).ThenBy(c => c.ContactLastName)
|
||||
: q.OrderByDescending(c => c.ContactFirstName).ThenByDescending(c => c.ContactLastName),
|
||||
@@ -100,7 +102,7 @@ public class CustomersController : Controller
|
||||
"CurrentBalance" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CurrentBalance) : q.OrderByDescending(c => c.CurrentBalance),
|
||||
"IsActive" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.IsActive) : q.OrderByDescending(c => c.IsActive),
|
||||
"LastContactDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.LastContactDate) : q.OrderByDescending(c => c.LastContactDate),
|
||||
_ => q => q.OrderBy(c => c.CompanyName)
|
||||
_ => q => q.OrderBy(c => c.CompanyName ?? c.ContactLastName)
|
||||
};
|
||||
|
||||
// Get paged data
|
||||
|
||||
@@ -1888,7 +1888,7 @@ public class InvoicesController : Controller
|
||||
/// Details view can show an inline toast with the delivery outcome.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ResendInvoice(int id, string? overrideEmail = null)
|
||||
public async Task<IActionResult> ResendInvoice(int id, string? overrideEmail = null, bool sendEmail = true, bool sendSms = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -1902,7 +1902,9 @@ public class InvoicesController : Controller
|
||||
if (invoice.Status == InvoiceStatus.Voided || invoice.Status == InvoiceStatus.WrittenOff)
|
||||
return Json(new { success = false, message = "Voided invoices cannot be resent." });
|
||||
|
||||
// Validate override email when provided
|
||||
if (!sendEmail && !sendSms)
|
||||
return Json(new { success = false, message = "Select at least one delivery channel (email or SMS)." });
|
||||
|
||||
overrideEmail = overrideEmail?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(overrideEmail) && !overrideEmail.Contains('@'))
|
||||
return Json(new { success = false, message = "The email address provided is not valid." });
|
||||
@@ -1915,32 +1917,55 @@ public class InvoicesController : Controller
|
||||
? overrideEmail
|
||||
: invoice.Customer?.BillingEmail ?? invoice.Customer?.Email ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(recipientEmail))
|
||||
if (sendEmail && string.IsNullOrWhiteSpace(recipientEmail))
|
||||
return Json(new { success = false, message = "No email address on file. Please provide an address to send to." });
|
||||
|
||||
// Ensure a permanent view token exists so the SMS link always works.
|
||||
string? viewUrl = null;
|
||||
if (sendSms)
|
||||
{
|
||||
if (string.IsNullOrEmpty(invoice.PublicViewToken))
|
||||
{
|
||||
invoice.PublicViewToken = Guid.NewGuid().ToString("N");
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
|
||||
}
|
||||
|
||||
byte[]? pdfBytes = null;
|
||||
string? pdfFilename = null;
|
||||
try
|
||||
if (sendEmail)
|
||||
{
|
||||
pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
|
||||
pdfFilename = $"Invoice-{invoice.InvoiceNumber}.pdf";
|
||||
}
|
||||
catch (Exception pdfEx)
|
||||
{
|
||||
_logger.LogWarning(pdfEx, "PDF generation failed during resend of invoice {Id}; sending without attachment", id);
|
||||
try
|
||||
{
|
||||
pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
|
||||
pdfFilename = $"Invoice-{invoice.InvoiceNumber}.pdf";
|
||||
}
|
||||
catch (Exception pdfEx)
|
||||
{
|
||||
_logger.LogWarning(pdfEx, "PDF generation failed during resend of invoice {Id}; sending without attachment", id);
|
||||
}
|
||||
}
|
||||
|
||||
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, pdfFilename, overrideEmail: overrideEmail);
|
||||
await _notificationService.NotifyInvoiceSentAsync(
|
||||
invoice, pdfBytes, pdfFilename,
|
||||
overrideEmail: overrideEmail,
|
||||
sendSms: sendSms,
|
||||
viewUrl: viewUrl);
|
||||
|
||||
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
||||
|
||||
if (latestLog?.Status == NotificationStatus.Failed)
|
||||
return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" });
|
||||
return Json(new { success = false, message = $"Delivery failed: {latestLog.ErrorMessage}" });
|
||||
|
||||
if (latestLog?.Status == NotificationStatus.Skipped)
|
||||
if (latestLog?.Status == NotificationStatus.Skipped && !sendSms)
|
||||
return Json(new { success = false, message = $"{recipientName} has email notifications disabled or no email address on file." });
|
||||
|
||||
return Json(new { success = true, message = $"Invoice sent to {recipientEmail}." });
|
||||
var channels = new List<string>();
|
||||
if (sendEmail && !string.IsNullOrWhiteSpace(recipientEmail)) channels.Add($"email ({recipientEmail})");
|
||||
if (sendSms) channels.Add("SMS");
|
||||
return Json(new { success = true, message = $"Invoice re-sent via {string.Join(" and ", channels)}." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -146,12 +146,19 @@ public class ReportsController : Controller
|
||||
var momGrowth = revenueLastMonth > 0 ? Math.Round((revenueThisMonth - revenueLastMonth) / revenueLastMonth * 100, 1) : 0m;
|
||||
|
||||
// === REVENUE ANALYTICS ===
|
||||
// Pre-filter completed jobs by date range once for monthly calculations
|
||||
var completedJobsInRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList();
|
||||
// CompletedDate is the authoritative "when the job finished" date.
|
||||
// UpdatedAt is set by the EF interceptor only on Modified saves, so seeded/imported
|
||||
// jobs may have UpdatedAt = null. Fall back to CreatedAt as a last resort.
|
||||
static DateTime JobMonthDate(Job j) =>
|
||||
j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt;
|
||||
|
||||
var completedJobsInRange = completedJobs
|
||||
.Where(j => JobMonthDate(j) >= startDate)
|
||||
.ToList();
|
||||
|
||||
// Group by month for efficient monthly aggregations
|
||||
var jobsByMonth = completedJobsInRange
|
||||
.GroupBy(j => new DateTime(j.UpdatedAt.Value.Year, j.UpdatedAt.Value.Month, 1))
|
||||
.GroupBy(j => { var d = JobMonthDate(j); return new DateTime(d.Year, d.Month, 1); })
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Monthly revenue trend
|
||||
|
||||
@@ -137,7 +137,7 @@ public class SeedDataController : Controller
|
||||
OperatingCosts = true,
|
||||
Bills = true,
|
||||
Expenses = true,
|
||||
Workers = true,
|
||||
Workers = false, // workers stay static — never deleted on reset
|
||||
Vendors = true,
|
||||
NamedOvens = true,
|
||||
Appointments = true,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
var isVoided = Model.Status == InvoiceStatus.Voided || Model.Status == InvoiceStatus.WrittenOff;
|
||||
var canEdit = Model.Status is InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue;
|
||||
var canPay = !isVoided && Model.BalanceDue > 0;
|
||||
var canResend = !isDraft && !isVoided && Model.Status != InvoiceStatus.Paid;
|
||||
var canResend = !isDraft && !isVoided;
|
||||
var hasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail);
|
||||
var emailOptedOut = hasEmail && !Model.CustomerNotifyByEmail;
|
||||
var smsPhone = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone) ? Model.CustomerMobilePhone : Model.CustomerPhone;
|
||||
@@ -654,23 +654,43 @@
|
||||
</a>
|
||||
@if (canResend)
|
||||
{
|
||||
@if (!hasEmail)
|
||||
@if (hasEmail && !emailOptedOut && hasSms)
|
||||
{
|
||||
@* Both email + SMS — channel choice modal *@
|
||||
<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
|
||||
data-bs-toggle="modal" data-bs-target="#resendChannelModal">
|
||||
<i class="bi bi-send me-2"></i>Re-send Invoice
|
||||
</button>
|
||||
}
|
||||
else if (emailOptedOut)
|
||||
else if (hasSms && (!hasEmail || emailOptedOut))
|
||||
{
|
||||
<button type="button" class="btn btn-outline-primary" disabled
|
||||
title="Email notifications are turned off for this customer">
|
||||
@* SMS only *@
|
||||
<button type="button" class="btn btn-outline-primary"
|
||||
onclick="resendInvoice(@Model.Id, null, false, true)">
|
||||
<i class="bi bi-phone me-2"></i>Re-send via SMS
|
||||
</button>
|
||||
}
|
||||
else if (hasEmail && !emailOptedOut)
|
||||
{
|
||||
@* Email only *@
|
||||
<button type="button" class="btn btn-outline-primary"
|
||||
onclick="resendInvoice(@Model.Id)">
|
||||
<i class="bi bi-send me-2"></i>Re-send Invoice
|
||||
</button>
|
||||
}
|
||||
else if (!hasEmail)
|
||||
{
|
||||
@* No email on file — let staff enter one *@
|
||||
<button type="button" class="btn btn-outline-primary"
|
||||
data-bs-toggle="modal" data-bs-target="#sendToAdHocEmailModal">
|
||||
<i class="bi bi-send me-2"></i>Re-send Invoice
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-outline-primary" onclick="resendInvoice(@Model.Id)">
|
||||
@* Email opted out, no SMS *@
|
||||
<button type="button" class="btn btn-outline-primary" disabled
|
||||
title="Email notifications are turned off for this customer and no mobile number is on file">
|
||||
<i class="bi bi-send me-2"></i>Re-send Invoice
|
||||
</button>
|
||||
}
|
||||
@@ -1138,6 +1158,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Re-send Channel Choice Modal (email opted-in + SMS both available) -->
|
||||
@if (canResend && hasEmail && !emailOptedOut && hasSms)
|
||||
{
|
||||
<div class="modal fade" id="resendChannelModal" tabindex="-1" aria-labelledby="resendChannelModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title" id="resendChannelModalLabel">
|
||||
<i class="bi bi-send text-primary me-2"></i>Re-send Invoice
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-2">
|
||||
<p class="mb-3">How would you like to re-send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-outline-primary text-start"
|
||||
onclick="resendInvoice(@Model.Id, null, true, false)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-envelope me-2"></i>Email only
|
||||
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary text-start"
|
||||
onclick="resendInvoice(@Model.Id, null, false, true)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-phone me-2"></i>SMS only
|
||||
<small class="d-block text-muted ms-4">View link · @smsPhone</small>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary text-start"
|
||||
onclick="resendInvoice(@Model.Id, null, true, true)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-send me-2"></i>Both Email & SMS
|
||||
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Notifications Sent Modal -->
|
||||
<div class="modal fade" id="invoiceNotificationsModal" tabindex="-1" aria-labelledby="invoiceNotificationsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
@@ -1537,7 +1597,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function resendInvoice(invoiceId, overrideEmail) {
|
||||
function resendInvoice(invoiceId, overrideEmail, sendEmail = true, sendSms = false) {
|
||||
document.getElementById('resendInvoiceSending').classList.remove('d-none');
|
||||
document.getElementById('resendInvoiceResult').classList.add('d-none');
|
||||
document.getElementById('resendInvoiceFooter').classList.add('d-none');
|
||||
@@ -1549,6 +1609,8 @@
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
let url = '@Url.Action("ResendInvoice", "Invoices")?id=' + invoiceId;
|
||||
if (overrideEmail) url += '&overrideEmail=' + encodeURIComponent(overrideEmail);
|
||||
url += '&sendEmail=' + (sendEmail ? 'true' : 'false');
|
||||
url += '&sendSms=' + (sendSms ? 'true' : 'false');
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -1567,11 +1629,11 @@
|
||||
if (data.success) {
|
||||
icon.className = 'bi bi-check-circle-fill text-success fs-1 d-block mb-3';
|
||||
header.className = 'modal-header bg-success text-white';
|
||||
showInfo(data.message, 'Email Sent');
|
||||
showInfo(data.message, 'Invoice Sent');
|
||||
} else {
|
||||
icon.className = 'bi bi-x-circle-fill text-danger fs-1 d-block mb-3';
|
||||
header.className = 'modal-header bg-danger text-white';
|
||||
showWarning(data.message, 'Email Not Sent');
|
||||
showWarning(data.message, 'Send Failed');
|
||||
}
|
||||
msg.textContent = data.message;
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user