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:
2026-06-11 13:20:04 -04:00
parent 249128e852
commit 7735fe3cce
16 changed files with 1142 additions and 487 deletions
@@ -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 &middot; @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 &middot; @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 &amp; 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;
})