Demo reset + dev banner suppression for DEMO company

- DemoController: company-code-gated reset action (DEMO only, CSRF protected)
- SeedDataService.Remove: FK-safe topological pre-sweep, all deletes scoped to companyId
- SeedDataService: clock entries, extra seed data, updated customer/worker/job-status seeders
- CompanySettingsController + Index.cshtml: Reset Demo Data button for DEMO company users
- ReportsController + FinancialReportService: supporting report fixes
- _Layout.cshtml: suppress env banner when current company is DEMO (all auth paths)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 09:26:40 -04:00
parent 7735fe3cce
commit 6eb7be0193
13 changed files with 963 additions and 221 deletions
@@ -220,12 +220,13 @@ public class ReportsController : Controller
// Top customers by revenue
var topCustomers = completedJobs
.Where(j => j.Customer != null)
.GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName })
.GroupBy(j => j.Customer!.Id)
.Select(g => new TopCustomerItem
{
Id = g.Key.Id,
Name = g.Key.CompanyName,
Revenue = g.Sum(j => j.FinalPrice),
Id = g.Key,
Name = g.First().Customer!.CompanyName
?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
Revenue = g.Sum(j => j.FinalPrice),
JobCount = g.Count()
})
.OrderByDescending(x => x.Revenue)
@@ -447,7 +448,9 @@ public class ReportsController : Controller
.SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem
{
InvoiceNumber = i.InvoiceNumber,
CustomerName = i.Customer?.CompanyName ?? i.Customer?.ContactLastName ?? string.Empty,
CustomerName = i.Customer?.CompanyName
?? $"{i.Customer?.ContactFirstName} {i.Customer?.ContactLastName}".Trim()
?? string.Empty,
Amount = p.Amount,
PaymentMethod = p.PaymentMethod.ToString(),
PaymentDate = p.PaymentDate
@@ -1360,8 +1363,15 @@ public class ReportsController : Controller
monthlyRevenue.Add(jobsByMonth.TryGetValue(ms, out var mj) ? mj.Sum(j => j.FinalPrice) : 0m);
}
var topCustomers = completedJobs.Where(j => j.Customer != null)
.GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName })
.Select(g => new TopCustomerItem { Id = g.Key.Id, Name = g.Key.CompanyName, Revenue = g.Sum(j => j.FinalPrice), JobCount = g.Count() })
.GroupBy(j => j.Customer!.Id)
.Select(g => new TopCustomerItem
{
Id = g.Key,
Name = g.First().Customer!.CompanyName
?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
Revenue = g.Sum(j => j.FinalPrice),
JobCount = g.Count()
})
.OrderByDescending(x => x.Revenue).Take(5).ToList();
var jobsByStatus = jobs.GroupBy(j => j.JobStatus.DisplayName)
.OrderBy(g => jobs.First(j => j.JobStatus.DisplayName == g.Key).JobStatus.DisplayOrder)
@@ -1415,8 +1425,15 @@ public class ReportsController : Controller
.OrderBy(g => completedJobs.First(j => j.JobPriority.DisplayName == g.Key).JobPriority.DisplayOrder)
.ToDictionary(g => g.Key, g => g.Sum(j => j.FinalPrice));
var topCustomers = completedJobs.Where(j => j.Customer != null)
.GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName })
.Select(g => new TopCustomerItem { Id = g.Key.Id, Name = g.Key.CompanyName, Revenue = g.Sum(j => j.FinalPrice), JobCount = g.Count() })
.GroupBy(j => j.Customer!.Id)
.Select(g => new TopCustomerItem
{
Id = g.Key,
Name = g.First().Customer!.CompanyName
?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
Revenue = g.Sum(j => j.FinalPrice),
JobCount = g.Count()
})
.OrderByDescending(x => x.Revenue).Take(10).ToList();
return View(new RevenueTrendsViewModel
{
@@ -1508,8 +1525,17 @@ public class ReportsController : Controller
var customersWithMultiple = completedJobs.Where(j => j.Customer != null).GroupBy(j => j.Customer!.Id).Count(g => g.Count() > 1);
var totalWithJobs = completedJobs.Where(j => j.Customer != null).Select(j => j.Customer!.Id).Distinct().Count();
var retentionRate = totalWithJobs > 0 ? Math.Round((decimal)customersWithMultiple / totalWithJobs * 100, 1) : 0m;
var clv = completedJobs.Where(j => j.Customer != null).GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName })
.Select(g => new CustomerLifetimeValueItem { CustomerName = g.Key.CompanyName, TotalRevenue = g.Sum(j => j.FinalPrice), JobCount = g.Count(), AvgOrderValue = g.Average(j => j.FinalPrice), FirstJobDate = g.Min(j => j.CreatedAt), LastJobDate = g.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt) })
var clv = completedJobs.Where(j => j.Customer != null).GroupBy(j => j.Customer!.Id)
.Select(g => new CustomerLifetimeValueItem
{
CustomerName = g.First().Customer!.CompanyName
?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
TotalRevenue = g.Sum(j => j.FinalPrice),
JobCount = g.Count(),
AvgOrderValue = g.Average(j => j.FinalPrice),
FirstJobDate = g.Min(j => j.CreatedAt),
LastJobDate = g.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt)
})
.OrderByDescending(c => c.TotalRevenue).Take(10).ToList();
var quotesByStatus = quotes.GroupBy(q => q.QuoteStatus.DisplayName).OrderBy(g => quotes.First(q => q.QuoteStatus.DisplayName == g.Key).QuoteStatus.DisplayOrder).ToDictionary(g => g.Key, g => g.Count());
var quoteFunnel = new QuoteConversionFunnel { Draft = quotes.Count(q => q.QuoteStatus.StatusCode == "DRAFT"), Sent = quotes.Count(q => q.QuoteStatus.StatusCode == "SENT"), Approved = quotes.Count(q => q.QuoteStatus.StatusCode == "APPROVED"), Converted = quotes.Count(q => q.QuoteStatus.StatusCode == "CONVERTED"), Rejected = quotes.Count(q => q.QuoteStatus.StatusCode == "REJECTED"), Expired = quotes.Count(q => q.QuoteStatus.StatusCode == "EXPIRED") };
@@ -1549,7 +1575,7 @@ public class ReportsController : Controller
var agingBuckets = new List<AgingBucketItem> { new() { Label = "Current (030 days)" }, new() { Label = "3160 days" }, new() { Label = "6190 days" }, new() { Label = "Over 90 days" } };
foreach (var inv in outstandingInvoices) { var days = inv.DueDate.HasValue ? (int)(today - inv.DueDate.Value).TotalDays : 0; var balance = inv.BalanceDue; var b = days <= 30 ? 0 : days <= 60 ? 1 : days <= 90 ? 2 : 3; agingBuckets[b].Amount += balance; agingBuckets[b].Count++; }
var recentPayments = activeInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem { InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer?.CompanyName ?? i.Customer?.ContactLastName ?? string.Empty, Amount = p.Amount, PaymentMethod = p.PaymentMethod.ToString(), PaymentDate = p.PaymentDate })).OrderByDescending(p => p.PaymentDate).Take(10).ToList();
var recentPayments = activeInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem { InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer?.CompanyName ?? $"{i.Customer?.ContactFirstName} {i.Customer?.ContactLastName}".Trim(), Amount = p.Amount, PaymentMethod = p.PaymentMethod.ToString(), PaymentDate = p.PaymentDate })).OrderByDescending(p => p.PaymentDate).Take(10).ToList();
var topOutstanding = outstandingInvoices.Where(i => i.Customer != null).GroupBy(i => new { i.CustomerId, Name = i.Customer!.CompanyName ?? $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() }).Select(g => new OutstandingCustomerItem { CustomerName = g.Key.Name, OutstandingBalance = g.Sum(i => i.BalanceDue), OpenInvoiceCount = g.Count() }).OrderByDescending(x => x.OutstandingBalance).Take(5).ToList();
var paidWithDates = allInvoices.Where(i => i.Status == InvoiceStatus.Paid && i.SentDate.HasValue && i.PaidDate.HasValue).ToList();
var avgDays = paidWithDates.Any() ? paidWithDates.Average(i => (i.PaidDate!.Value - i.SentDate!.Value).TotalDays) : 0.0;