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
@@ -115,19 +115,26 @@ public partial class SeedDataService
if (workers.Count == 0) return 0;
// Only create entries for jobs that have been worked on
var activeJobs = await _context.Set<Job>()
// Resolve status IDs first — avoids relying on Include(j => j.JobStatus) which can
// silently return null navigation properties when query filters interact with IgnoreQueryFilters.
var workedStatusIds = await _context.Set<JobStatusLookup>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted)
.Include(j => j.JobStatus)
.Where(s => s.CompanyId == company.Id && new[]
{
"IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING",
"IN_OVEN", "COATING", "CURING", "QUALITY_CHECK",
"COMPLETED", "READY_FOR_PICKUP", "DELIVERED"
}.Contains(s.StatusCode))
.Select(s => s.Id)
.ToListAsync();
var workedJobs = activeJobs.Where(j =>
j.JobStatus?.StatusCode is
"IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING" or "CLEANING" or
"IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK" or
"COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED"
).ToList();
if (workedStatusIds.Count == 0) return 0;
var workedJobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
&& workedStatusIds.Contains(j.JobStatusId))
.ToListAsync();
if (workedJobs.Count == 0) return 0;