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();
}
@@ -8,8 +8,8 @@ namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Implements <see cref="IDashboardReadService"/> using <see cref="ApplicationDbContext"/> directly.
/// All queries require ThenInclude chains or navigation-property predicates (e.g. JobStatus.StatusCode)
/// that cannot be expressed through the generic <see cref="PowderCoating.Core.Interfaces.IRepository{T}"/>.
/// All queries require navigation-property predicates, eager loading, or aggregate projections that
/// do not fit the generic repository abstraction well.
/// </summary>
public class DashboardReadService : IDashboardReadService
{
@@ -21,6 +21,18 @@ public class DashboardReadService : IDashboardReadService
"CANCELLED"
];
private static readonly string[] InProgressStatusCodes =
[
"IN_PREPARATION",
"SANDBLASTING",
"MASKING_TAPING",
"CLEANING",
"IN_OVEN",
"COATING",
"CURING",
"QUALITY_CHECK"
];
private readonly ApplicationDbContext _context;
public DashboardReadService(ApplicationDbContext context)
@@ -31,190 +43,406 @@ public class DashboardReadService : IDashboardReadService
/// <inheritdoc/>
public async Task<DashboardIndexData> GetIndexDataAsync(DateTime today)
{
var startOfMonth = new DateTime(today.Year, today.Month, 1);
var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1);
var tomorrow = today.AddDays(1);
var lookAheadDate = today.AddDays(7);
var startOfMonth = new DateTime(today.Year, today.Month, 1);
var startOfNextMonth = startOfMonth.AddMonths(1);
var lookAheadInclusive = today.AddDays(8);
var last30Days = today.AddDays(-30);
var days30Ago = today.AddDays(-30);
var days60Ago = today.AddDays(-60);
var days90Ago = today.AddDays(-90);
var openInvoiceStatuses = new[] { InvoiceStatus.Sent, InvoiceStatus.PartiallyPaid, InvoiceStatus.Overdue };
// All active jobs (for today/overdue/in-progress panels)
var activeJobs = await _context.Jobs
var activeJobsBase = _context.Jobs
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.AssignedUser)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority)
.Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode))
.Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode));
var todaysJobsFilter = activeJobsBase.Where(j =>
(j.ScheduledDate.HasValue && j.ScheduledDate.Value >= today && j.ScheduledDate.Value < tomorrow) ||
(j.DueDate.HasValue && j.DueDate.Value >= today && j.DueDate.Value < tomorrow));
var overdueJobsFilter = activeJobsBase.Where(j =>
j.DueDate.HasValue && j.DueDate.Value < today);
var inProgressJobsFilter = activeJobsBase.Where(j =>
InProgressStatusCodes.Contains(j.JobStatus.StatusCode));
var activeJobsCount = await activeJobsBase.CountAsync();
var todaysJobsCount = await todaysJobsFilter.CountAsync();
var overdueJobsCount = await overdueJobsFilter.CountAsync();
var todaysJobs = await WithDashboardJobIncludes(todaysJobsFilter)
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate ?? j.DueDate)
.Take(10)
.ToListAsync();
// Monthly revenue — sum completed jobs updated in current month
var monthlyRevenue = await _context.Jobs
.Include(j => j.JobStatus)
.Where(j => CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.UpdatedAt >= startOfMonth
&& j.UpdatedAt <= endOfMonth)
.SumAsync(j => j.FinalPrice);
var overdueJobs = await WithDashboardJobIncludes(overdueJobsFilter)
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.DueDate)
.Take(10)
.ToListAsync();
// Today's appointments (non-cancelled)
var todaysAppointments = await _context.Appointments
var inProgressJobs = await WithDashboardJobIncludes(inProgressJobsFilter)
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate)
.Take(10)
.ToListAsync();
var todaysAppointmentsBase = _context.Appointments
.AsNoTracking()
.Include(a => a.Customer)
.Include(a => a.AppointmentType)
.Include(a => a.AppointmentStatus)
.Include(a => a.AssignedUser)
.Where(a => a.ScheduledStartTime >= today && a.ScheduledStartTime < tomorrow
&& a.AppointmentStatus.StatusCode != "CANCELLED")
.Where(a =>
a.ScheduledStartTime >= today &&
a.ScheduledStartTime < tomorrow &&
a.AppointmentStatus.StatusCode != "CANCELLED");
var todaysAppointmentsCount = await todaysAppointmentsBase.CountAsync();
var todaysAppointments = await WithDashboardAppointmentIncludes(todaysAppointmentsBase)
.OrderBy(a => a.ScheduledStartTime)
.Take(10)
.ToListAsync();
// Upcoming/overdue maintenance
var upcomingMaintenance = await _context.MaintenanceRecords
var lowStockBase = _context.InventoryItems
.AsNoTracking()
.Include(m => m.Equipment)
.Include(m => m.AssignedUser)
.Where(m => (m.Status == MaintenanceStatus.Scheduled
|| m.Status == MaintenanceStatus.InProgress
|| m.Status == MaintenanceStatus.Overdue)
&& (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAheadDate))
.Where(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
var lowStockCount = await lowStockBase.CountAsync();
var lowStockItems = await lowStockBase
.OrderBy(i => i.QuantityOnHand)
.Take(10)
.ToListAsync();
var maintenanceBase = _context.MaintenanceRecords
.AsNoTracking()
.Where(m =>
(m.Status == MaintenanceStatus.Scheduled ||
m.Status == MaintenanceStatus.InProgress ||
m.Status == MaintenanceStatus.Overdue) &&
(m.Status == MaintenanceStatus.Overdue || m.ScheduledDate < lookAheadInclusive));
var pendingMaintenanceCount = await maintenanceBase.CountAsync();
var upcomingMaintenance = await WithDashboardMaintenanceIncludes(maintenanceBase)
.OrderBy(m => m.Status == MaintenanceStatus.Overdue ? 0 : 1)
.ThenByDescending(m => m.Priority)
.ThenBy(m => m.ScheduledDate)
.Take(10)
.ToListAsync();
// Pending quotes (SENT status)
var pendingQuotes = await _context.Quotes
var pendingQuotesBase = _context.Quotes
.AsNoTracking()
.Include(q => q.Customer)
.Include(q => q.QuoteStatus)
.Where(q => q.QuoteStatus.StatusCode == "SENT")
.Where(q => q.QuoteStatus.StatusCode == "SENT");
var pendingQuotesCount = await pendingQuotesBase.CountAsync();
var pendingQuoteValue = await pendingQuotesBase
.Select(q => (decimal?)q.Total)
.SumAsync() ?? 0m;
var pendingQuotes = await WithDashboardQuoteIncludes(pendingQuotesBase)
.OrderBy(q => q.ExpirationDate)
.ThenBy(q => q.QuoteDate)
.Take(10)
.ToListAsync();
// Open invoices (for AR aging + overdue list)
var openInvoices = await _context.Invoices
.AsNoTracking()
.Include(i => i.Customer)
.Where(i => openInvoiceStatuses.Contains(i.Status))
var expiringQuotes = await WithDashboardQuoteIncludes(pendingQuotesBase)
.Where(q => q.ExpirationDate.HasValue &&
q.ExpirationDate.Value >= today &&
q.ExpirationDate.Value < lookAheadInclusive)
.OrderBy(q => q.ExpirationDate)
.ThenBy(q => q.QuoteDate)
.Take(10)
.ToListAsync();
// Invoiced this month
var activeCustomersCount = await _context.Customers
.AsNoTracking()
.CountAsync(c => c.IsActive);
var monthlyRevenue = await _context.Jobs
.AsNoTracking()
.Where(j =>
CompletedStatusCodes.Contains(j.JobStatus.StatusCode) &&
j.UpdatedAt >= startOfMonth &&
j.UpdatedAt < startOfNextMonth)
.Select(j => (decimal?)j.FinalPrice)
.SumAsync() ?? 0m;
var openInvoicesBase = _context.Invoices
.AsNoTracking()
.Where(i => openInvoiceStatuses.Contains(i.Status));
var overdueInvoicesBase = openInvoicesBase.Where(i =>
i.DueDate.HasValue && i.DueDate.Value < today);
var outstandingAr = await openInvoicesBase
.Select(i => (decimal?)(i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed))
.SumAsync() ?? 0m;
var invoicedThisMonth = await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.Status != InvoiceStatus.WrittenOff
&& i.InvoiceDate >= startOfMonth
&& i.InvoiceDate <= endOfMonth)
.SumAsync(i => i.Total);
.AsNoTracking()
.Where(i =>
i.Status != InvoiceStatus.Draft &&
i.Status != InvoiceStatus.Voided &&
i.Status != InvoiceStatus.WrittenOff &&
i.InvoiceDate >= startOfMonth &&
i.InvoiceDate < startOfNextMonth)
.Select(i => (decimal?)i.Total)
.SumAsync() ?? 0m;
// Collected this month
var collectedThisMonth = await _context.Payments
.Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate <= endOfMonth)
.SumAsync(p => p.Amount);
.AsNoTracking()
.Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate < startOfNextMonth)
.Select(p => (decimal?)p.Amount)
.SumAsync() ?? 0m;
var overdueInvoicesCount = await overdueInvoicesBase.CountAsync();
var overdueInvoicesAmount = await overdueInvoicesBase
.Select(i => (decimal?)(i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed))
.SumAsync() ?? 0m;
var aging = new DashboardArAgingData(
Current: await openInvoicesBase
.Where(i => !i.DueDate.HasValue || i.DueDate.Value >= today)
.Select(i => (decimal?)(i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed))
.SumAsync() ?? 0m,
Days1To30: await openInvoicesBase
.Where(i => i.DueDate.HasValue && i.DueDate.Value < today && i.DueDate.Value >= days30Ago)
.Select(i => (decimal?)(i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed))
.SumAsync() ?? 0m,
Days31To60: await openInvoicesBase
.Where(i => i.DueDate.HasValue && i.DueDate.Value < days30Ago && i.DueDate.Value >= days60Ago)
.Select(i => (decimal?)(i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed))
.SumAsync() ?? 0m,
Days61To90: await openInvoicesBase
.Where(i => i.DueDate.HasValue && i.DueDate.Value < days60Ago && i.DueDate.Value >= days90Ago)
.Select(i => (decimal?)(i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed))
.SumAsync() ?? 0m,
DaysOver90: await openInvoicesBase
.Where(i => i.DueDate.HasValue && i.DueDate.Value < days90Ago)
.Select(i => (decimal?)(i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed))
.SumAsync() ?? 0m
);
var overdueInvoices = await openInvoicesBase
.Where(i => i.DueDate.HasValue && i.DueDate.Value < today)
.Include(i => i.Customer)
.OrderBy(i => i.DueDate)
.Take(6)
.ToListAsync();
// Recent payments with Invoice → Customer
var recentPayments = await _context.Payments
.AsNoTracking()
.Include(p => p.Invoice).ThenInclude(i => i!.Customer)
.Include(p => p.Invoice)
.ThenInclude(i => i!.Customer)
.OrderByDescending(p => p.PaymentDate)
.Take(6)
.ToListAsync();
// Recent quotes (last 30 days)
var recentQuotes = await _context.Quotes
var equipmentAlerts = await _context.Equipment
.AsNoTracking()
.Include(q => q.Customer)
.Include(q => q.QuoteStatus)
.Where(q => q.CreatedAt >= last30Days)
.Where(e =>
e.Status == EquipmentStatus.NeedsMaintenance ||
e.Status == EquipmentStatus.OutOfService)
.OrderByDescending(e => e.Status == EquipmentStatus.OutOfService ? 1 : 0)
.ThenBy(e => e.EquipmentName)
.Take(5)
.ToListAsync();
var recentQuotes = await WithDashboardQuoteIncludes(_context.Quotes
.AsNoTracking()
.Where(q => q.CreatedAt >= last30Days))
.OrderByDescending(q => q.CreatedAt)
.Take(5)
.ToListAsync();
// Recent jobs (last 30 days)
var recentJobs = await _context.Jobs
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Where(j => j.CreatedAt >= last30Days)
var recentJobs = await WithDashboardJobIncludes(_context.Jobs
.AsNoTracking()
.Where(j => j.CreatedAt >= last30Days))
.OrderByDescending(j => j.CreatedAt)
.Take(5)
.ToListAsync();
// Jobs needing powder (not yet ordered, insufficient stock)
var jobsNeedingPowder = await _context.Jobs
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.InventoryItem)
.ThenInclude(inv => inv!.PrimaryVendor)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.Vendor)
.Where(j => !j.IsDeleted
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.JobItems.Any(i => i.Coats.Any(c =>
!c.IsDeleted &&
!c.PowderOrdered &&
c.PowderToOrder > 0 &&
(c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder))))
.ToListAsync();
var powderOrdersNeeded = await BuildPowderOrderQuery(orderedOnly: false).ToListAsync();
var powderOrdersPlaced = await BuildPowderOrderQuery(orderedOnly: true).ToListAsync();
// Jobs with powder already ordered but not yet received
var jobsWithOrderedPowder = await _context.Jobs
var billsDueBase = _context.Bills
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.InventoryItem)
.ThenInclude(inv => inv!.PrimaryVendor)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.Vendor)
.Where(j => !j.IsDeleted
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.JobItems.Any(i => i.Coats.Any(c =>
!c.IsDeleted &&
c.PowderOrdered &&
!c.PowderReceived)))
.ToListAsync();
.Where(b =>
(b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid) &&
b.Total > b.AmountPaid);
// Bills due (open/partial, balance remaining)
var billsDue = await _context.Bills
.AsNoTracking()
var billsDueCount = await billsDueBase.CountAsync();
var billsDueAmount = await billsDueBase
.Select(b => (decimal?)(b.Total - b.AmountPaid))
.SumAsync() ?? 0m;
var billsDue = await billsDueBase
.Include(b => b.Vendor)
.Where(b => (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid)
&& b.Total > b.AmountPaid)
.OrderBy(b => b.DueDate)
.Take(15)
.ToListAsync();
// Random tip of the day
var tips = await _context.DashboardTips.Where(t => t.IsActive).ToListAsync();
var tipOfTheDay = tips.Count > 0 ? tips[Random.Shared.Next(tips.Count)].TipText : null;
var tipCount = await _context.DashboardTips
.AsNoTracking()
.CountAsync(t => t.IsActive);
string? tipOfTheDay = null;
if (tipCount > 0)
{
var tipIndex = Random.Shared.Next(tipCount);
tipOfTheDay = await _context.DashboardTips
.AsNoTracking()
.Where(t => t.IsActive)
.OrderBy(t => t.Id)
.Select(t => t.TipText)
.Skip(tipIndex)
.FirstOrDefaultAsync();
}
return new DashboardIndexData(
ActiveJobs: activeJobs,
MonthlyRevenue: monthlyRevenue,
ActiveJobsCount: activeJobsCount,
TodaysJobsCount: todaysJobsCount,
TodaysJobs: todaysJobs,
OverdueJobsCount: overdueJobsCount,
OverdueJobs: overdueJobs,
InProgressJobs: inProgressJobs,
TodaysAppointmentsCount: todaysAppointmentsCount,
TodaysAppointments: todaysAppointments,
LowStockCount: lowStockCount,
LowStockItems: lowStockItems,
PendingMaintenanceCount: pendingMaintenanceCount,
UpcomingMaintenance: upcomingMaintenance,
PendingQuotesCount: pendingQuotesCount,
PendingQuoteValue: pendingQuoteValue,
PendingQuotes: pendingQuotes,
OpenInvoices: openInvoices,
ExpiringQuotes: expiringQuotes,
ActiveCustomersCount: activeCustomersCount,
MonthlyRevenue: monthlyRevenue,
OutstandingAr: outstandingAr,
InvoicedThisMonth: invoicedThisMonth,
CollectedThisMonth: collectedThisMonth,
OverdueInvoicesCount: overdueInvoicesCount,
OverdueInvoicesAmount: overdueInvoicesAmount,
ArAging: aging,
OverdueInvoices: overdueInvoices,
RecentPayments: recentPayments,
RecentQuotes: recentQuotes,
RecentJobs: recentJobs,
JobsNeedingPowder: jobsNeedingPowder,
JobsWithOrderedPowder: jobsWithOrderedPowder,
EquipmentAlerts: equipmentAlerts,
PowderOrdersNeeded: powderOrdersNeeded,
PowderOrdersPlaced: powderOrdersPlaced,
BillsDueCount: billsDueCount,
BillsDueAmount: billsDueAmount,
BillsDue: billsDue,
TipOfTheDay: tipOfTheDay
);
}
/// <inheritdoc/>
public async Task<SuperAdminDashboardData> GetSuperAdminDashboardDataAsync(DateTime today)
{
var companies = _context.Companies
.AsNoTracking()
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted);
var summary = await companies
.GroupBy(_ => 1)
.Select(g => new
{
TotalCompanies = g.Count(),
ActiveCompanies = g.Count(c => c.IsActive),
InactiveCompanies = g.Count(c => !c.IsActive),
ActiveSubscriptions = g.Count(c => c.SubscriptionStatus == SubscriptionStatus.Active),
GracePeriodCount = g.Count(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod),
ExpiredCount = g.Count(c =>
c.SubscriptionStatus == SubscriptionStatus.Expired ||
c.SubscriptionStatus == SubscriptionStatus.Canceled)
})
.FirstOrDefaultAsync();
var totalUsers = await _context.Users
.Where(u => u.CompanyId > 0)
.CountAsync();
var planConfigs = await _context.SubscriptionPlanConfigs
.AsNoTracking()
.IgnoreQueryFilters()
.Where(c => c.IsActive)
.OrderBy(c => c.SortOrder)
.Select(c => new { c.Plan, c.DisplayName })
.ToListAsync();
var planLookup = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName);
string PlanName(int plan) => planLookup.TryGetValue(plan, out var name) ? name : plan.ToString();
var planCounts = await companies
.GroupBy(c => c.SubscriptionPlan)
.Select(g => new { Plan = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Plan, x => x.Count);
var companyAlerts = await companies
.Where(c => c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value.Date < today)
.OrderBy(c => c.SubscriptionEndDate)
.Take(20)
.Select(c => new
{
c.Id,
c.CompanyName,
c.SubscriptionPlan,
c.SubscriptionStatus,
c.SubscriptionEndDate,
c.IsActive
})
.ToListAsync();
var recentCompanies = await companies
.OrderByDescending(c => c.CreatedAt)
.Take(10)
.Select(c => new
{
c.Id,
c.CompanyName,
c.SubscriptionPlan,
c.SubscriptionStatus,
c.IsActive,
c.CreatedAt
})
.ToListAsync();
var planDistribution = planConfigs.ToDictionary(
c => c.Plan,
c => new DashboardPlanDistributionData(
c.DisplayName,
planCounts.TryGetValue(c.Plan, out var count) ? count : 0));
return new SuperAdminDashboardData(
TotalCompanies: summary?.TotalCompanies ?? 0,
ActiveCompanies: summary?.ActiveCompanies ?? 0,
InactiveCompanies: summary?.InactiveCompanies ?? 0,
TotalUsers: totalUsers,
ActiveSubscriptions: summary?.ActiveSubscriptions ?? 0,
GracePeriodCount: summary?.GracePeriodCount ?? 0,
ExpiredCount: summary?.ExpiredCount ?? 0,
PlanDistribution: planDistribution,
CompanyAlerts: companyAlerts.Select(c => new SuperAdminCompanyAlertData(
c.Id,
c.CompanyName,
c.SubscriptionPlan,
PlanName(c.SubscriptionPlan),
c.SubscriptionStatus,
c.SubscriptionEndDate,
c.SubscriptionEndDate.HasValue ? (int)(today - c.SubscriptionEndDate.Value.Date).TotalDays : 0,
c.IsActive)).ToList(),
RecentCompanies: recentCompanies.Select(c => new SuperAdminRecentCompanyData(
c.Id,
c.CompanyName,
c.SubscriptionPlan,
PlanName(c.SubscriptionPlan),
c.SubscriptionStatus,
c.IsActive,
c.CreatedAt)).ToList()
);
}
/// <inheritdoc/>
public async Task<int> GetTotalUserCountAsync()
{
@@ -222,4 +450,76 @@ public class DashboardReadService : IDashboardReadService
.Where(u => u.CompanyId > 0)
.CountAsync();
}
private static IQueryable<Job> WithDashboardJobIncludes(IQueryable<Job> query) =>
query.Include(j => j.Customer)
.Include(j => j.AssignedUser)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority);
private static IQueryable<Appointment> WithDashboardAppointmentIncludes(IQueryable<Appointment> query) =>
query.Include(a => a.Customer)
.Include(a => a.AppointmentType)
.Include(a => a.AppointmentStatus)
.Include(a => a.AssignedUser);
private static IQueryable<MaintenanceRecord> WithDashboardMaintenanceIncludes(IQueryable<MaintenanceRecord> query) =>
query.Include(m => m.Equipment)
.Include(m => m.AssignedUser);
private static IQueryable<Quote> WithDashboardQuoteIncludes(IQueryable<Quote> query) =>
query.Include(q => q.Customer)
.Include(q => q.QuoteStatus);
private IQueryable<DashboardPowderOrderLineData> BuildPowderOrderQuery(bool orderedOnly)
{
var coats = _context.JobItemCoats
.AsNoTracking()
.Where(c =>
!c.IsDeleted &&
!c.JobItem.IsDeleted &&
!c.JobItem.Job.IsDeleted &&
!CompletedStatusCodes.Contains(c.JobItem.Job.JobStatus.StatusCode));
coats = orderedOnly
? coats.Where(c => c.PowderOrdered && !c.PowderReceived)
: coats.Where(c =>
!c.PowderOrdered &&
c.PowderToOrder > 0 &&
(c.InventoryItemId == null || (c.InventoryItem != null && c.InventoryItem.QuantityOnHand < c.PowderToOrder)));
return coats.Select(c => new DashboardPowderOrderLineData(
c.Id,
c.JobItem.JobId,
c.JobItem.Job.JobNumber,
c.JobItem.Job.Customer != null
? (c.JobItem.Job.Customer.CompanyName ?? c.JobItem.Job.Customer.ContactFirstName ?? "Unknown")
: "Unknown",
c.CoatName,
c.ColorName ?? (c.InventoryItem != null ? c.InventoryItem.ColorName : null),
c.ColorCode ?? (c.InventoryItem != null ? c.InventoryItem.ColorCode : null),
c.Finish ?? (c.InventoryItem != null ? c.InventoryItem.Finish : null),
c.InventoryItem != null ? c.InventoryItem.SKU : null,
c.PowderToOrder ?? 0m,
c.PowderCostPerLb ?? (c.InventoryItem != null ? c.InventoryItem.UnitCost : null),
orderedOnly ? c.PowderOrderedAt : null,
c.InventoryItemId.HasValue,
c.VendorId ?? (c.InventoryItem != null ? c.InventoryItem.PrimaryVendorId : null),
c.Vendor != null
? c.Vendor.CompanyName
: c.InventoryItem != null && c.InventoryItem.PrimaryVendor != null
? c.InventoryItem.PrimaryVendor.CompanyName
: null,
c.Vendor != null
? c.Vendor.Phone
: c.InventoryItem != null && c.InventoryItem.PrimaryVendor != null
? c.InventoryItem.PrimaryVendor.Phone
: null,
c.Vendor != null
? c.Vendor.Email
: c.InventoryItem != null && c.InventoryItem.PrimaryVendor != null
? c.InventoryItem.PrimaryVendor.Email
: null
));
}
}
@@ -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
@@ -1,4 +1,5 @@
using System.Security.Claims;
using Microsoft.Extensions.Caching.Memory;
using PowderCoating.Web.Services;
namespace PowderCoating.Web.Middleware;
@@ -10,22 +11,18 @@ namespace PowderCoating.Web.Middleware;
public class OnlineUserMiddleware
{
private readonly RequestDelegate _next;
/// <summary>
/// Maps each user ID to the UTC time the tracker was last updated.
/// A <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>
/// is used because multiple concurrent requests from the same user (e.g. polling
/// and a page navigation) may race to update the entry. The dictionary is
/// static so the throttle window persists across DI scope lifetimes — the
/// middleware instance itself may be recreated but the throttle state must not.
/// </summary>
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _throttle = new();
private readonly IMemoryCache _cache;
/// <summary>
/// Initialises the middleware with the next request delegate in the pipeline.
/// </summary>
/// <param name="next">The next middleware component.</param>
public OnlineUserMiddleware(RequestDelegate next) => _next = next;
/// <param name="cache">Shared app cache used for expiring per-user throttle entries.</param>
public OnlineUserMiddleware(RequestDelegate next, IMemoryCache cache)
{
_next = next;
_cache = cache;
}
/// <summary>
/// Calls the downstream pipeline first, then — after the response is
@@ -63,12 +60,15 @@ public class OnlineUserMiddleware
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return;
// Throttle: only update the tracker once per 60 seconds per user
// Throttle: only update the tracker once per 60 seconds per user.
// IMemoryCache automatically expires old entries so the throttle state
// does not grow without bound across the lifetime of the process.
var now = DateTime.UtcNow;
if (_throttle.TryGetValue(userId, out var lastWrite) && (now - lastWrite).TotalSeconds < 60)
var throttleKey = $"online-user-touch:{userId}";
if (_cache.TryGetValue(throttleKey, out _))
return;
_throttle[userId] = now;
_cache.Set(throttleKey, true, TimeSpan.FromSeconds(60));
var email = context.User.FindFirstValue(ClaimTypes.Email) ?? string.Empty;
var firstName = context.User.FindFirstValue(ClaimTypes.GivenName) ?? string.Empty;