From 2b89fcf483f97ace67f7066f30f8eb6458156ee6 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Fri, 1 May 2026 10:00:43 -0400 Subject: [PATCH] Refactor dashboard queries to push filtering and aggregation into the database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Services/IDashboardReadService.cs | 116 +++- .../Services/DashboardReadService.cs | 538 ++++++++++++++---- .../Controllers/DashboardController.cs | 422 ++++---------- .../Middleware/OnlineUserMiddleware.cs | 28 +- 4 files changed, 647 insertions(+), 457 deletions(-) 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;