Refactor dashboard queries to push filtering and aggregation into the database

DashboardReadService no longer loads full entity lists and filters in memory.
All job panels (today/overdue/in-progress) now execute targeted COUNT + capped
SELECT queries in SQL. AR aging buckets, powder order lines, bill totals, and
active-customer counts are all aggregated at the DB level. The SuperAdmin action
previously loaded every company row to compute plan distribution and alert lists;
it now delegates to a new GetSuperAdminDashboardDataAsync() that uses SQL GROUP BY
and projections instead.

DashboardIndexData record updated to carry pre-sliced counts and capped lists so
the controller only does lightweight DTO projection. DashboardPowderOrderLineData
replaces the deep Job→JobItem→Coat Include chains with a single flat coat query
projected in SQL. OnlineUserMiddleware switches its per-user throttle from a
static ConcurrentDictionary (grows forever) to IMemoryCache with a 60-second
sliding expiry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 10:00:43 -04:00
parent 0b798cadb4
commit 2b89fcf483
4 changed files with 647 additions and 457 deletions
@@ -26,26 +26,6 @@ public class DashboardController : Controller
private readonly UserManager<ApplicationUser> _userManager;
private readonly ISubscriptionService _subscriptionService;
private static readonly string[] CompletedStatusCodes =
[
"COMPLETED",
"READY_FOR_PICKUP",
"DELIVERED",
"CANCELLED"
];
private static readonly string[] InProgressStatusCodes =
[
"IN_PREPARATION",
"SANDBLASTING",
"MASKING_TAPING",
"CLEANING",
"IN_OVEN",
"COATING",
"CURING",
"QUALITY_CHECK"
];
public DashboardController(
IUnitOfWork unitOfWork,
ILogger<DashboardController> logger,
@@ -79,48 +59,27 @@ public class DashboardController : Controller
try
{
var today = DateTime.Today;
var lookAheadDate = today.AddDays(7);
var data = await _dashboardRead.GetIndexDataAsync(today);
// ---------------------------------------------------------------
// Job panels — in-memory split of the pre-fetched activeJobs list
// Job panels
// ---------------------------------------------------------------
var todaysJobsFiltered = data.ActiveJobs
.Where(j => (j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today) ||
(j.DueDate.HasValue && j.DueDate.Value.Date == today));
var todaysJobsCount = todaysJobsFiltered.Count();
var todaysJobs = todaysJobsFiltered
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate ?? j.DueDate)
.Take(10)
var todaysJobs = data.TodaysJobs
.Select(MapJobDto)
.ToList();
var overdueJobsFiltered = data.ActiveJobs
.Where(j => j.DueDate.HasValue && j.DueDate.Value.Date < today);
var overdueJobsCount = overdueJobsFiltered.Count();
var overdueJobs = overdueJobsFiltered
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.DueDate)
.Take(10)
var overdueJobs = data.OverdueJobs
.Select(MapJobDto)
.ToList();
var inProgressJobs = data.ActiveJobs
.Where(j => InProgressStatusCodes.Contains(j.JobStatus.StatusCode))
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate)
.Take(10)
var inProgressJobs = data.InProgressJobs
.Select(MapJobDto)
.ToList();
// ---------------------------------------------------------------
// Appointments
// ---------------------------------------------------------------
var todaysAppointmentsCount = data.TodaysAppointments.Count;
var todaysAppointments = data.TodaysAppointments
.Take(10)
.Select(a => new DashboardAppointmentDto
{
Id = a.Id,
@@ -140,12 +99,7 @@ public class DashboardController : Controller
// ---------------------------------------------------------------
// Low stock items
// ---------------------------------------------------------------
var lowStockAll = await _unitOfWork.InventoryItems.FindAsync(
i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
var lowStockCount = lowStockAll.Count();
var lowStockItems = lowStockAll
.OrderBy(i => i.QuantityOnHand)
.Take(10)
var lowStockItems = data.LowStockItems
.Select(i => new DashboardLowStockDto
{
Id = i.Id,
@@ -177,8 +131,6 @@ public class DashboardController : Controller
// Quotes
// ---------------------------------------------------------------
var pendingQuotes = data.PendingQuotes
.OrderBy(q => q.ExpirationDate)
.Take(10)
.Select(q => new DashboardQuoteDto
{
Id = q.Id,
@@ -195,14 +147,7 @@ public class DashboardController : Controller
StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList();
var pendingQuoteValue = data.PendingQuotes.Sum(q => q.Total);
var expiringQuotes = data.PendingQuotes
.Where(q => q.ExpirationDate.HasValue
&& q.ExpirationDate.Value.Date >= today
&& q.ExpirationDate.Value.Date <= lookAheadDate)
.OrderBy(q => q.ExpirationDate)
.Take(10)
var expiringQuotes = data.ExpiringQuotes
.Select(q => new DashboardQuoteDto
{
Id = q.Id,
@@ -219,26 +164,10 @@ public class DashboardController : Controller
StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList();
// ---------------------------------------------------------------
// Active customers
// ---------------------------------------------------------------
var activeCustomersCount = await _unitOfWork.Customers.CountAsync(c => c.IsActive);
// ---------------------------------------------------------------
// Invoices & AR aging
// ---------------------------------------------------------------
var outstandingAr = data.OpenInvoices.Sum(i => i.BalanceDue);
var overdueInvoicesList = data.OpenInvoices
.Where(i => i.DueDate.HasValue && i.DueDate.Value.Date < today)
.OrderBy(i => i.DueDate)
.ToList();
var overdueInvoicesCount = overdueInvoicesList.Count;
var overdueInvoicesAmount = overdueInvoicesList.Sum(i => i.BalanceDue);
var overdueInvoices = overdueInvoicesList
.Take(6)
var overdueInvoices = data.OverdueInvoices
.Select(i => new DashboardInvoiceDto
{
Id = i.Id,
@@ -252,24 +181,6 @@ public class DashboardController : Controller
})
.ToList();
// AR Aging buckets
decimal agingCurrent = 0, aging1To30 = 0, aging31To60 = 0, aging61To90 = 0, agingOver90 = 0;
foreach (var inv in data.OpenInvoices)
{
if (!inv.DueDate.HasValue || inv.DueDate.Value.Date >= today)
{
agingCurrent += inv.BalanceDue;
}
else
{
var daysLate = (int)(today - inv.DueDate.Value.Date).TotalDays;
if (daysLate <= 30) aging1To30 += inv.BalanceDue;
else if (daysLate <= 60) aging31To60 += inv.BalanceDue;
else if (daysLate <= 90) aging61To90 += inv.BalanceDue;
else agingOver90 += inv.BalanceDue;
}
}
// ---------------------------------------------------------------
// Payments
// ---------------------------------------------------------------
@@ -278,7 +189,7 @@ public class DashboardController : Controller
{
Id = p.Id,
InvoiceId = p.InvoiceId,
InvoiceNumber = p.Invoice?.InvoiceNumber ?? "",
InvoiceNumber = p.Invoice?.InvoiceNumber ?? "-",
CustomerName = p.Invoice?.Customer?.CompanyName
?? $"{p.Invoice?.Customer?.ContactFirstName} {p.Invoice?.Customer?.ContactLastName}".Trim(),
Amount = p.Amount,
@@ -298,11 +209,7 @@ public class DashboardController : Controller
// ---------------------------------------------------------------
// Equipment alerts
// ---------------------------------------------------------------
var equipmentAlerts = (await _unitOfWork.Equipment.FindAsync(
e => e.Status == EquipmentStatus.NeedsMaintenance ||
e.Status == EquipmentStatus.OutOfService))
.OrderByDescending(e => e.Status == EquipmentStatus.OutOfService ? 1 : 0)
.Take(5)
var equipmentAlerts = data.EquipmentAlerts
.Select(e => new DashboardEquipmentAlertDto
{
Id = e.Id,
@@ -359,134 +266,12 @@ public class DashboardController : Controller
// ---------------------------------------------------------------
// Powder orders needed
// ---------------------------------------------------------------
var powderFlat = data.JobsNeedingPowder
.SelectMany(j => j.JobItems
.SelectMany(i => i.Coats
.Where(c => !c.IsDeleted && !c.PowderOrdered && c.PowderToOrder > 0
&& (c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder))
.Select(c =>
{
var vendor = c.Vendor ?? c.InventoryItem?.PrimaryVendor;
return new
{
CoatId = c.Id,
JobId = j.Id,
JobNumber = j.JobNumber,
CustomerName = j.Customer?.CompanyName ?? j.Customer?.ContactFirstName ?? "Unknown",
CoatName = c.CoatName,
ColorName = c.ColorName ?? c.InventoryItem?.ColorName,
ColorCode = c.ColorCode ?? c.InventoryItem?.ColorCode,
Finish = c.Finish ?? c.InventoryItem?.Finish,
SKU = c.InventoryItem?.SKU,
LbsToOrder = c.PowderToOrder!.Value,
CostPerLb = c.PowderCostPerLb ?? c.InventoryItem?.UnitCost,
VendorId = vendor?.Id,
VendorName = vendor?.CompanyName,
VendorPhone = vendor?.Phone,
VendorEmail = vendor?.Email,
};
})))
.ToList();
var powderOrderGroups = powderFlat
.GroupBy(l => l.VendorId)
.Select(g =>
{
var first = g.First();
return new PowderOrderVendorGroupDto
{
VendorId = g.Key,
VendorName = first.VendorName ?? "No Vendor Assigned",
VendorPhone = first.VendorPhone,
VendorEmail = first.VendorEmail,
TotalLbsNeeded = g.Sum(l => l.LbsToOrder),
TotalEstCost = g.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0),
Lines = g.Select(l => new PowderOrderLineDto
{
CoatId = l.CoatId,
JobId = l.JobId,
JobNumber = l.JobNumber,
CustomerName = l.CustomerName,
CoatName = l.CoatName,
ColorName = l.ColorName,
ColorCode = l.ColorCode,
Finish = l.Finish,
SKU = l.SKU,
LbsToOrder = l.LbsToOrder,
CostPerLb = l.CostPerLb,
}).OrderBy(l => l.JobNumber).ThenBy(l => l.CoatName).ToList()
};
})
.OrderBy(g => g.VendorName)
.ToList();
var powderOrderGroups = MapPowderOrderGroups(data.PowderOrdersNeeded);
// ---------------------------------------------------------------
// Powder orders placed
// ---------------------------------------------------------------
var placedFlat = data.JobsWithOrderedPowder
.SelectMany(j => j.JobItems
.SelectMany(i => i.Coats
.Where(c => !c.IsDeleted && c.PowderOrdered && !c.PowderReceived)
.Select(c =>
{
var vendor = c.Vendor ?? c.InventoryItem?.PrimaryVendor;
return new
{
CoatId = c.Id,
JobId = j.Id,
JobNumber = j.JobNumber,
CustomerName = j.Customer?.CompanyName ?? j.Customer?.ContactFirstName ?? "Unknown",
CoatName = c.CoatName,
ColorName = c.ColorName ?? c.InventoryItem?.ColorName,
ColorCode = c.ColorCode ?? c.InventoryItem?.ColorCode,
Finish = c.Finish ?? c.InventoryItem?.Finish,
SKU = c.InventoryItem?.SKU,
LbsToOrder = c.PowderToOrder ?? 0m,
CostPerLb = c.PowderCostPerLb ?? c.InventoryItem?.UnitCost,
OrderedAt = c.PowderOrderedAt,
HasInventoryItem = c.InventoryItemId.HasValue,
VendorId = vendor?.Id,
VendorName = vendor?.CompanyName,
VendorPhone = vendor?.Phone,
VendorEmail = vendor?.Email,
};
})))
.ToList();
var powderPlacedGroups = placedFlat
.GroupBy(l => l.VendorId)
.Select(g =>
{
var first = g.First();
return new PowderOrderVendorGroupDto
{
VendorId = g.Key,
VendorName = first.VendorName ?? "No Vendor Assigned",
VendorPhone = first.VendorPhone,
VendorEmail = first.VendorEmail,
TotalLbsNeeded = g.Sum(l => l.LbsToOrder),
TotalEstCost = g.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0),
Lines = g.Select(l => new PowderOrderLineDto
{
CoatId = l.CoatId,
JobId = l.JobId,
JobNumber = l.JobNumber,
CustomerName = l.CustomerName,
CoatName = l.CoatName,
ColorName = l.ColorName,
ColorCode = l.ColorCode,
Finish = l.Finish,
SKU = l.SKU,
LbsToOrder = l.LbsToOrder,
CostPerLb = l.CostPerLb,
OrderedAt = l.OrderedAt,
HasInventoryItem = l.HasInventoryItem,
VendorId = l.VendorId,
}).OrderBy(l => l.OrderedAt).ThenBy(l => l.JobNumber).ToList()
};
})
.OrderBy(g => g.VendorName)
.ToList();
var powderPlacedGroups = MapPowderOrderGroups(data.PowderOrdersPlaced);
// ---------------------------------------------------------------
// Bills due
@@ -495,7 +280,7 @@ public class DashboardController : Controller
{
Id = b.Id,
BillNumber = b.BillNumber,
VendorName = b.Vendor.CompanyName,
VendorName = b.Vendor?.CompanyName ?? "Unknown",
BalanceDue = b.BalanceDue,
DueDate = b.DueDate,
IsOverdue = b.DueDate.HasValue && b.DueDate.Value.Date < today,
@@ -506,28 +291,28 @@ public class DashboardController : Controller
var vm = new DashboardViewModel
{
// Counts
ActiveJobsCount = data.ActiveJobs.Count,
TodaysJobsCount = todaysJobsCount,
OverdueJobsCount = overdueJobsCount,
TodaysAppointmentsCount = todaysAppointmentsCount,
LowStockCount = lowStockCount,
PendingMaintenanceCount = data.UpcomingMaintenance.Count,
PendingQuotesCount = data.PendingQuotes.Count,
PendingQuoteValue = pendingQuoteValue,
ActiveJobsCount = data.ActiveJobsCount,
TodaysJobsCount = data.TodaysJobsCount,
OverdueJobsCount = data.OverdueJobsCount,
TodaysAppointmentsCount = data.TodaysAppointmentsCount,
LowStockCount = data.LowStockCount,
PendingMaintenanceCount = data.PendingMaintenanceCount,
PendingQuotesCount = data.PendingQuotesCount,
PendingQuoteValue = data.PendingQuoteValue,
MonthlyRevenue = data.MonthlyRevenue,
ActiveCustomersCount = activeCustomersCount,
ActiveCustomersCount = data.ActiveCustomersCount,
// Financial KPIs
OutstandingAr = outstandingAr,
OutstandingAr = data.OutstandingAr,
CollectedThisMonth = data.CollectedThisMonth,
InvoicedThisMonth = data.InvoicedThisMonth,
OverdueInvoicesCount = overdueInvoicesCount,
OverdueInvoicesAmount = overdueInvoicesAmount,
AgingCurrent = agingCurrent,
AgingDays1To30 = aging1To30,
AgingDays31To60 = aging31To60,
AgingDays61To90 = aging61To90,
AgingDaysOver90 = agingOver90,
OverdueInvoicesCount = data.OverdueInvoicesCount,
OverdueInvoicesAmount = data.OverdueInvoicesAmount,
AgingCurrent = data.ArAging.Current,
AgingDays1To30 = data.ArAging.Days1To30,
AgingDays31To60 = data.ArAging.Days31To60,
AgingDays61To90 = data.ArAging.Days61To90,
AgingDaysOver90 = data.ArAging.DaysOver90,
// Sections
TodaysJobs = todaysJobs,
@@ -545,14 +330,14 @@ public class DashboardController : Controller
// Bills Due
BillsDue = billsDue,
BillsDueCount = billsDue.Count,
BillsDueAmount = billsDue.Sum(b => b.BalanceDue),
BillsDueCount = data.BillsDueCount,
BillsDueAmount = data.BillsDueAmount,
// Powder orders
PowderOrdersNeeded = powderOrderGroups,
PowderOrdersNeededCount = powderFlat.Count,
PowderOrdersNeededCount = data.PowderOrdersNeeded.Count,
PowderOrdersPlaced = powderPlacedGroups,
PowderOrdersPlacedCount = placedFlat.Count,
PowderOrdersPlacedCount = data.PowderOrdersPlaced.Count,
TipOfTheDay = data.TipOfTheDay
};
@@ -767,7 +552,7 @@ public class DashboardController : Controller
DoneSubLabel = "Your payment defaults are locked in.",
Icon = "bi-file-earmark-text",
CtaText = "Set payment terms",
CtaUrl = Url.Action("Index", "CompanySettings") + "#general"
CtaUrl = Url.Action("Index", "CompanySettings") + "#app-defaults"
}
};
@@ -1040,76 +825,41 @@ public class DashboardController : Controller
try
{
var today = DateTime.Today;
var allCompanies = await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true);
var companies = allCompanies.Where(c => !c.IsDeleted).ToList();
var totalUsers = await _dashboardRead.GetTotalUserCountAsync();
var graceCutoff = today.AddDays(-AppConstants.SubscriptionConstants.GracePeriodDays);
var planConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true))
.OrderBy(c => c.SortOrder)
.ToList();
var planLookup = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName);
string PlanName(int plan) => planLookup.TryGetValue(plan, out var name) ? name : plan.ToString();
var companyAlerts = companies
.Where(c => c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value.Date < today)
.OrderBy(c => c.SubscriptionEndDate)
.Take(20)
.Select(c =>
{
var daysOverdue = (int)(today - c.SubscriptionEndDate!.Value.Date).TotalDays;
return new PlatformCompanyAlertDto
{
Id = c.Id,
CompanyName = c.CompanyName,
Plan = c.SubscriptionPlan,
PlanDisplayName = PlanName(c.SubscriptionPlan),
Status = c.SubscriptionStatus,
SubscriptionEndDate = c.SubscriptionEndDate,
DaysOverdue = daysOverdue,
IsActive = c.IsActive
};
})
.ToList();
var recentCompanies = companies
.OrderByDescending(c => c.CreatedAt)
.Take(10)
.Select(c => new PlatformRecentCompanyDto
{
Id = c.Id,
CompanyName = c.CompanyName,
Plan = c.SubscriptionPlan,
PlanDisplayName = PlanName(c.SubscriptionPlan),
Status = c.SubscriptionStatus,
IsActive = c.IsActive,
CreatedAt = c.CreatedAt
})
.ToList();
var planDistribution = planConfigs.ToDictionary(
c => c.Plan,
c => (c.DisplayName, companies.Count(comp => comp.SubscriptionPlan == c.Plan)));
var data = await _dashboardRead.GetSuperAdminDashboardDataAsync(today);
var vm = new SuperAdminDashboardViewModel
{
TotalCompanies = companies.Count,
ActiveCompanies = companies.Count(c => c.IsActive),
InactiveCompanies = companies.Count(c => !c.IsActive),
TotalUsers = totalUsers,
PlanDistribution = planDistribution,
ActiveSubscriptions = companies.Count(c => c.SubscriptionStatus == SubscriptionStatus.Active),
GracePeriodCount = companies.Count(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod),
ExpiredCount = companies.Count(c =>
c.SubscriptionStatus == SubscriptionStatus.Expired ||
c.SubscriptionStatus == SubscriptionStatus.Canceled),
CompanyAlerts = companyAlerts,
RecentCompanies = recentCompanies
TotalCompanies = data.TotalCompanies,
ActiveCompanies = data.ActiveCompanies,
InactiveCompanies = data.InactiveCompanies,
TotalUsers = data.TotalUsers,
PlanDistribution = data.PlanDistribution.ToDictionary(
kvp => kvp.Key,
kvp => (kvp.Value.DisplayName, kvp.Value.Count)),
ActiveSubscriptions = data.ActiveSubscriptions,
GracePeriodCount = data.GracePeriodCount,
ExpiredCount = data.ExpiredCount,
CompanyAlerts = data.CompanyAlerts.Select(c => new PlatformCompanyAlertDto
{
Id = c.Id,
CompanyName = c.CompanyName,
Plan = c.Plan,
PlanDisplayName = c.PlanDisplayName,
Status = c.Status,
SubscriptionEndDate = c.SubscriptionEndDate,
DaysOverdue = c.DaysOverdue,
IsActive = c.IsActive
}).ToList(),
RecentCompanies = data.RecentCompanies.Select(c => new PlatformRecentCompanyDto
{
Id = c.Id,
CompanyName = c.CompanyName,
Plan = c.Plan,
PlanDisplayName = c.PlanDisplayName,
Status = c.Status,
IsActive = c.IsActive,
CreatedAt = c.CreatedAt
}).ToList()
};
return View(vm);
@@ -1122,6 +872,46 @@ public class DashboardController : Controller
}
}
private static List<PowderOrderVendorGroupDto> MapPowderOrderGroups(
IEnumerable<DashboardPowderOrderLineData> lines) =>
lines.GroupBy(l => l.VendorId)
.Select(g =>
{
var first = g.First();
return new PowderOrderVendorGroupDto
{
VendorId = g.Key,
VendorName = first.VendorName ?? "No Vendor Assigned",
VendorPhone = first.VendorPhone,
VendorEmail = first.VendorEmail,
TotalLbsNeeded = g.Sum(l => l.LbsToOrder),
TotalEstCost = g.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0m),
Lines = g.Select(l => new PowderOrderLineDto
{
CoatId = l.CoatId,
JobId = l.JobId,
JobNumber = l.JobNumber,
CustomerName = l.CustomerName,
CoatName = l.CoatName,
ColorName = l.ColorName,
ColorCode = l.ColorCode,
Finish = l.Finish,
SKU = l.SKU,
LbsToOrder = l.LbsToOrder,
CostPerLb = l.CostPerLb,
OrderedAt = l.OrderedAt,
HasInventoryItem = l.HasInventoryItem,
VendorId = l.VendorId
})
.OrderBy(l => l.OrderedAt ?? DateTime.MinValue)
.ThenBy(l => l.JobNumber)
.ThenBy(l => l.CoatName)
.ToList()
};
})
.OrderBy(g => g.VendorName)
.ToList();
/// <summary>
/// Projects a <see cref="Core.Entities.Job"/> into a lightweight <see cref="DashboardJobDto"/>
/// for use in dashboard job lists. Centralising the mapping in one static helper ensures that