diff --git a/src/PowderCoating.Core/Interfaces/Services/IDashboardReadService.cs b/src/PowderCoating.Core/Interfaces/Services/IDashboardReadService.cs
index c9e0435..33d6124 100644
--- a/src/PowderCoating.Core/Interfaces/Services/IDashboardReadService.cs
+++ b/src/PowderCoating.Core/Interfaces/Services/IDashboardReadService.cs
@@ -1,30 +1,127 @@
using PowderCoating.Core.Entities;
+using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Services;
///
-/// 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.
///
public record DashboardIndexData(
- List ActiveJobs,
- decimal MonthlyRevenue,
+ int ActiveJobsCount,
+ int TodaysJobsCount,
+ List TodaysJobs,
+ int OverdueJobsCount,
+ List OverdueJobs,
+ List InProgressJobs,
+ int TodaysAppointmentsCount,
List TodaysAppointments,
+ int LowStockCount,
+ List LowStockItems,
+ int PendingMaintenanceCount,
List UpcomingMaintenance,
+ int PendingQuotesCount,
+ decimal PendingQuoteValue,
List PendingQuotes,
- List OpenInvoices,
+ List ExpiringQuotes,
+ int ActiveCustomersCount,
+ decimal MonthlyRevenue,
+ decimal OutstandingAr,
decimal InvoicedThisMonth,
decimal CollectedThisMonth,
+ int OverdueInvoicesCount,
+ decimal OverdueInvoicesAmount,
+ DashboardArAgingData ArAging,
+ List OverdueInvoices,
List RecentPayments,
List RecentQuotes,
List RecentJobs,
- List JobsNeedingPowder,
- List JobsWithOrderedPowder,
+ List EquipmentAlerts,
+ List PowderOrdersNeeded,
+ List PowderOrdersPlaced,
+ int BillsDueCount,
+ decimal BillsDueAmount,
List BillsDue,
string? TipOfTheDay
);
+///
+/// AR aging bucket totals used by the dashboard receivables summary.
+///
+public record DashboardArAgingData(
+ decimal Current,
+ decimal Days1To30,
+ decimal Days31To60,
+ decimal Days61To90,
+ decimal DaysOver90
+);
+
+///
+/// Flattened powder-order line data so the controller does not need to materialize full job/item/coat graphs.
+///
+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
+);
+
+///
+/// Aggregated data for the SuperAdmin dashboard.
+///
+public record SuperAdminDashboardData(
+ int TotalCompanies,
+ int ActiveCompanies,
+ int InactiveCompanies,
+ int TotalUsers,
+ int ActiveSubscriptions,
+ int GracePeriodCount,
+ int ExpiredCount,
+ Dictionary PlanDistribution,
+ List CompanyAlerts,
+ List 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
+);
+
///
/// 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
/// The local date used for date-range predicates (today, start-of-month, etc.).
Task GetIndexDataAsync(DateTime today);
+ /// Fetches all data needed to render the SuperAdmin dashboard.
+ Task GetSuperAdminDashboardDataAsync(DateTime today);
+
/// Returns the total count of tenant users (CompanyId > 0) for the SuperAdmin dashboard.
Task GetTotalUserCountAsync();
}
diff --git a/src/PowderCoating.Infrastructure/Services/DashboardReadService.cs b/src/PowderCoating.Infrastructure/Services/DashboardReadService.cs
index 7cad882..c14ffee 100644
--- a/src/PowderCoating.Infrastructure/Services/DashboardReadService.cs
+++ b/src/PowderCoating.Infrastructure/Services/DashboardReadService.cs
@@ -8,8 +8,8 @@ namespace PowderCoating.Infrastructure.Services;
///
/// Implements using directly.
-/// All queries require ThenInclude chains or navigation-property predicates (e.g. JobStatus.StatusCode)
-/// that cannot be expressed through the generic .
+/// All queries require navigation-property predicates, eager loading, or aggregate projections that
+/// do not fit the generic repository abstraction well.
///
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
///
public async Task 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
);
}
+ ///
+ public async Task 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()
+ );
+ }
+
///
public async Task GetTotalUserCountAsync()
{
@@ -222,4 +450,76 @@ public class DashboardReadService : IDashboardReadService
.Where(u => u.CompanyId > 0)
.CountAsync();
}
+
+ private static IQueryable WithDashboardJobIncludes(IQueryable query) =>
+ query.Include(j => j.Customer)
+ .Include(j => j.AssignedUser)
+ .Include(j => j.JobStatus)
+ .Include(j => j.JobPriority);
+
+ private static IQueryable WithDashboardAppointmentIncludes(IQueryable query) =>
+ query.Include(a => a.Customer)
+ .Include(a => a.AppointmentType)
+ .Include(a => a.AppointmentStatus)
+ .Include(a => a.AssignedUser);
+
+ private static IQueryable WithDashboardMaintenanceIncludes(IQueryable query) =>
+ query.Include(m => m.Equipment)
+ .Include(m => m.AssignedUser);
+
+ private static IQueryable WithDashboardQuoteIncludes(IQueryable query) =>
+ query.Include(q => q.Customer)
+ .Include(q => q.QuoteStatus);
+
+ private IQueryable 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
+ ));
+ }
}
diff --git a/src/PowderCoating.Web/Controllers/DashboardController.cs b/src/PowderCoating.Web/Controllers/DashboardController.cs
index 5705a49..7faab93 100644
--- a/src/PowderCoating.Web/Controllers/DashboardController.cs
+++ b/src/PowderCoating.Web/Controllers/DashboardController.cs
@@ -26,26 +26,6 @@ public class DashboardController : Controller
private readonly UserManager _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 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 MapPowderOrderGroups(
+ IEnumerable 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();
+
///
/// Projects a into a lightweight
/// for use in dashboard job lists. Centralising the mapping in one static helper ensures that
diff --git a/src/PowderCoating.Web/Middleware/OnlineUserMiddleware.cs b/src/PowderCoating.Web/Middleware/OnlineUserMiddleware.cs
index 69e0a27..cf7d15b 100644
--- a/src/PowderCoating.Web/Middleware/OnlineUserMiddleware.cs
+++ b/src/PowderCoating.Web/Middleware/OnlineUserMiddleware.cs
@@ -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;
-
- ///
- /// Maps each user ID to the UTC time the tracker was last updated.
- /// A
- /// 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.
- ///
- private static readonly System.Collections.Concurrent.ConcurrentDictionary _throttle = new();
+ private readonly IMemoryCache _cache;
///
/// Initialises the middleware with the next request delegate in the pipeline.
///
/// The next middleware component.
- public OnlineUserMiddleware(RequestDelegate next) => _next = next;
+ /// Shared app cache used for expiring per-user throttle entries.
+ public OnlineUserMiddleware(RequestDelegate next, IMemoryCache cache)
+ {
+ _next = next;
+ _cache = cache;
+ }
///
/// 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;