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
@@ -54,17 +54,25 @@ public partial class SeedDataService
if (items.Count == 0) return 0;
// Load completed/delivered jobs to generate usage transactions against
var completedJobs = await _context.Set<Job>()
// Two-query approach: resolve status IDs first to avoid Include() navigation
// returning null when global query filters interact with IgnoreQueryFilters().
var completedStatusIds = await _context.Set<JobStatusLookup>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
&& (j.JobStatus.StatusCode == "COMPLETED"
|| j.JobStatus.StatusCode == "READY_FOR_PICKUP"
|| j.JobStatus.StatusCode == "DELIVERED"))
.Include(j => j.JobStatus)
.OrderBy(j => j.Id)
.Where(s => s.CompanyId == company.Id
&& new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }.Contains(s.StatusCode))
.Select(s => s.Id)
.ToListAsync();
var completedJobs = completedStatusIds.Count == 0
? new List<Job>()
: await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
&& completedStatusIds.Contains(j.JobStatusId))
.Include(j => j.JobItems)
.OrderBy(j => j.Id)
.ToListAsync();
var now = DateTime.UtcNow;
var seeded = 0;
var txns = new List<InventoryTransaction>();
@@ -90,14 +98,18 @@ public partial class SeedDataService
});
}
// ── Purchase transactions — 3 months of restocks ──────────────────────
// Simulate monthly powder purchases for top items
var powderItems = items.Take(8).ToList(); // focus on powder coat items
foreach (var (offset, qtyMult) in new (int daysAgo, decimal mult)[] {
(85, 1.2m), (55, 1.0m), (25, 0.9m) })
// ── Purchase transactions — 12 months of monthly restocks ────────────
var powderItems = items.Take(8).ToList();
// Quantities vary slightly month to month to give the inventory chart a natural shape
var purchaseOffsets = new (int daysAgo, decimal mult)[]
{
foreach (var item in powderItems.Take(4)) // 4 items per purchase cycle
(365, 0.80m), (335, 0.85m), (305, 0.90m), (275, 0.95m),
(245, 1.00m), (215, 1.05m), (185, 1.10m), (155, 1.10m),
(125, 1.00m), ( 95, 1.10m), ( 65, 1.00m), ( 35, 0.95m)
};
foreach (var (offset, qtyMult) in purchaseOffsets)
{
foreach (var item in powderItems.Take(4))
{
var qty = Math.Round(25m * qtyMult, 0);
txns.Add(new InventoryTransaction
@@ -144,9 +156,10 @@ public partial class SeedDataService
foreach (var (job, idx) in completedJobs.Select((j, i) => (j, i)))
{
// Completed date spread: most within the last 60 days
var daysAgo = 10 + (idx % 55);
var usageDate = now.AddDays(-daysAgo);
// Use the job's actual completion date so powder usage history spans the same
// 12-month window as jobs, giving the Powder Usage report non-trivial data in
// every month rather than clustering everything in the last 60 days.
var usageDate = (job.CompletedDate ?? job.ScheduledDate ?? now.AddDays(-30)).Date;
// Pick a color-matched powder item (or rotate)
var firstItem = job.JobItems?.FirstOrDefault();