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
@@ -1,30 +1,127 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Services;
/// <summary>
/// Result record carrying all pre-fetched entity lists and aggregates needed to render the operator
/// dashboard index view. Raw entities are returned so the controller can apply in-memory
/// filtering, grouping, and DTO projection without additional round-trips.
/// Result record carrying the pre-sliced entity lists and aggregates needed to render the
/// operator dashboard index view. The read service does the heavy SQL filtering so the
/// controller can focus on lightweight DTO projection and view assembly.
/// </summary>
public record DashboardIndexData(
List<Job> ActiveJobs,
decimal MonthlyRevenue,
int ActiveJobsCount,
int TodaysJobsCount,
List<Job> TodaysJobs,
int OverdueJobsCount,
List<Job> OverdueJobs,
List<Job> InProgressJobs,
int TodaysAppointmentsCount,
List<Appointment> TodaysAppointments,
int LowStockCount,
List<InventoryItem> LowStockItems,
int PendingMaintenanceCount,
List<MaintenanceRecord> UpcomingMaintenance,
int PendingQuotesCount,
decimal PendingQuoteValue,
List<Quote> PendingQuotes,
List<Invoice> OpenInvoices,
List<Quote> ExpiringQuotes,
int ActiveCustomersCount,
decimal MonthlyRevenue,
decimal OutstandingAr,
decimal InvoicedThisMonth,
decimal CollectedThisMonth,
int OverdueInvoicesCount,
decimal OverdueInvoicesAmount,
DashboardArAgingData ArAging,
List<Invoice> OverdueInvoices,
List<Payment> RecentPayments,
List<Quote> RecentQuotes,
List<Job> RecentJobs,
List<Job> JobsNeedingPowder,
List<Job> JobsWithOrderedPowder,
List<Equipment> EquipmentAlerts,
List<DashboardPowderOrderLineData> PowderOrdersNeeded,
List<DashboardPowderOrderLineData> PowderOrdersPlaced,
int BillsDueCount,
decimal BillsDueAmount,
List<Bill> BillsDue,
string? TipOfTheDay
);
/// <summary>
/// AR aging bucket totals used by the dashboard receivables summary.
/// </summary>
public record DashboardArAgingData(
decimal Current,
decimal Days1To30,
decimal Days31To60,
decimal Days61To90,
decimal DaysOver90
);
/// <summary>
/// Flattened powder-order line data so the controller does not need to materialize full job/item/coat graphs.
/// </summary>
public record DashboardPowderOrderLineData(
int CoatId,
int JobId,
string JobNumber,
string CustomerName,
string CoatName,
string? ColorName,
string? ColorCode,
string? Finish,
string? SKU,
decimal LbsToOrder,
decimal? CostPerLb,
DateTime? OrderedAt,
bool HasInventoryItem,
int? VendorId,
string? VendorName,
string? VendorPhone,
string? VendorEmail
);
/// <summary>
/// Aggregated data for the SuperAdmin dashboard.
/// </summary>
public record SuperAdminDashboardData(
int TotalCompanies,
int ActiveCompanies,
int InactiveCompanies,
int TotalUsers,
int ActiveSubscriptions,
int GracePeriodCount,
int ExpiredCount,
Dictionary<int, DashboardPlanDistributionData> PlanDistribution,
List<SuperAdminCompanyAlertData> CompanyAlerts,
List<SuperAdminRecentCompanyData> RecentCompanies
);
public record DashboardPlanDistributionData(
string DisplayName,
int Count
);
public record SuperAdminCompanyAlertData(
int Id,
string CompanyName,
int Plan,
string PlanDisplayName,
SubscriptionStatus Status,
DateTime? SubscriptionEndDate,
int DaysOverdue,
bool IsActive
);
public record SuperAdminRecentCompanyData(
int Id,
string CompanyName,
int Plan,
string PlanDisplayName,
SubscriptionStatus Status,
bool IsActive,
DateTime CreatedAt
);
/// <summary>
/// Read-only service for the dashboard. All methods execute complex queries that require
/// ThenInclude chains or navigation-property predicates beyond what the generic
@@ -37,6 +134,9 @@ public interface IDashboardReadService
/// <param name="today">The local date used for date-range predicates (today, start-of-month, etc.).</param>
Task<DashboardIndexData> GetIndexDataAsync(DateTime today);
/// <summary>Fetches all data needed to render the SuperAdmin dashboard.</summary>
Task<SuperAdminDashboardData> GetSuperAdminDashboardDataAsync(DateTime today);
/// <summary>Returns the total count of tenant users (CompanyId > 0) for the SuperAdmin dashboard.</summary>
Task<int> GetTotalUserCountAsync();
}