Refactor dashboard queries to push filtering and aggregation into the database

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 10:00:43 -04:00
parent 0b798cadb4
commit 2b89fcf483
4 changed files with 647 additions and 457 deletions
@@ -1,30 +1,127 @@
using PowderCoating.Core.Entities; using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Services; namespace PowderCoating.Core.Interfaces.Services;
/// <summary> /// <summary>
/// Result record carrying all pre-fetched entity lists and aggregates needed to render the operator /// Result record carrying the pre-sliced entity lists and aggregates needed to render the
/// dashboard index view. Raw entities are returned so the controller can apply in-memory /// operator dashboard index view. The read service does the heavy SQL filtering so the
/// filtering, grouping, and DTO projection without additional round-trips. /// controller can focus on lightweight DTO projection and view assembly.
/// </summary> /// </summary>
public record DashboardIndexData( public record DashboardIndexData(
List<Job> ActiveJobs, int ActiveJobsCount,
decimal MonthlyRevenue, int TodaysJobsCount,
List<Job> TodaysJobs,
int OverdueJobsCount,
List<Job> OverdueJobs,
List<Job> InProgressJobs,
int TodaysAppointmentsCount,
List<Appointment> TodaysAppointments, List<Appointment> TodaysAppointments,
int LowStockCount,
List<InventoryItem> LowStockItems,
int PendingMaintenanceCount,
List<MaintenanceRecord> UpcomingMaintenance, List<MaintenanceRecord> UpcomingMaintenance,
int PendingQuotesCount,
decimal PendingQuoteValue,
List<Quote> PendingQuotes, List<Quote> PendingQuotes,
List<Invoice> OpenInvoices, List<Quote> ExpiringQuotes,
int ActiveCustomersCount,
decimal MonthlyRevenue,
decimal OutstandingAr,
decimal InvoicedThisMonth, decimal InvoicedThisMonth,
decimal CollectedThisMonth, decimal CollectedThisMonth,
int OverdueInvoicesCount,
decimal OverdueInvoicesAmount,
DashboardArAgingData ArAging,
List<Invoice> OverdueInvoices,
List<Payment> RecentPayments, List<Payment> RecentPayments,
List<Quote> RecentQuotes, List<Quote> RecentQuotes,
List<Job> RecentJobs, List<Job> RecentJobs,
List<Job> JobsNeedingPowder, List<Equipment> EquipmentAlerts,
List<Job> JobsWithOrderedPowder, List<DashboardPowderOrderLineData> PowderOrdersNeeded,
List<DashboardPowderOrderLineData> PowderOrdersPlaced,
int BillsDueCount,
decimal BillsDueAmount,
List<Bill> BillsDue, List<Bill> BillsDue,
string? TipOfTheDay string? TipOfTheDay
); );
/// <summary>
/// AR aging bucket totals used by the dashboard receivables summary.
/// </summary>
public record DashboardArAgingData(
decimal Current,
decimal Days1To30,
decimal Days31To60,
decimal Days61To90,
decimal DaysOver90
);
/// <summary>
/// Flattened powder-order line data so the controller does not need to materialize full job/item/coat graphs.
/// </summary>
public record DashboardPowderOrderLineData(
int CoatId,
int JobId,
string JobNumber,
string CustomerName,
string CoatName,
string? ColorName,
string? ColorCode,
string? Finish,
string? SKU,
decimal LbsToOrder,
decimal? CostPerLb,
DateTime? OrderedAt,
bool HasInventoryItem,
int? VendorId,
string? VendorName,
string? VendorPhone,
string? VendorEmail
);
/// <summary>
/// Aggregated data for the SuperAdmin dashboard.
/// </summary>
public record SuperAdminDashboardData(
int TotalCompanies,
int ActiveCompanies,
int InactiveCompanies,
int TotalUsers,
int ActiveSubscriptions,
int GracePeriodCount,
int ExpiredCount,
Dictionary<int, DashboardPlanDistributionData> PlanDistribution,
List<SuperAdminCompanyAlertData> CompanyAlerts,
List<SuperAdminRecentCompanyData> RecentCompanies
);
public record DashboardPlanDistributionData(
string DisplayName,
int Count
);
public record SuperAdminCompanyAlertData(
int Id,
string CompanyName,
int Plan,
string PlanDisplayName,
SubscriptionStatus Status,
DateTime? SubscriptionEndDate,
int DaysOverdue,
bool IsActive
);
public record SuperAdminRecentCompanyData(
int Id,
string CompanyName,
int Plan,
string PlanDisplayName,
SubscriptionStatus Status,
bool IsActive,
DateTime CreatedAt
);
/// <summary> /// <summary>
/// Read-only service for the dashboard. All methods execute complex queries that require /// Read-only service for the dashboard. All methods execute complex queries that require
/// ThenInclude chains or navigation-property predicates beyond what the generic /// ThenInclude chains or navigation-property predicates beyond what the generic
@@ -37,6 +134,9 @@ public interface IDashboardReadService
/// <param name="today">The local date used for date-range predicates (today, start-of-month, etc.).</param> /// <param name="today">The local date used for date-range predicates (today, start-of-month, etc.).</param>
Task<DashboardIndexData> GetIndexDataAsync(DateTime today); Task<DashboardIndexData> GetIndexDataAsync(DateTime today);
/// <summary>Fetches all data needed to render the SuperAdmin dashboard.</summary>
Task<SuperAdminDashboardData> GetSuperAdminDashboardDataAsync(DateTime today);
/// <summary>Returns the total count of tenant users (CompanyId > 0) for the SuperAdmin dashboard.</summary> /// <summary>Returns the total count of tenant users (CompanyId > 0) for the SuperAdmin dashboard.</summary>
Task<int> GetTotalUserCountAsync(); Task<int> GetTotalUserCountAsync();
} }
@@ -8,8 +8,8 @@ namespace PowderCoating.Infrastructure.Services;
/// <summary> /// <summary>
/// Implements <see cref="IDashboardReadService"/> using <see cref="ApplicationDbContext"/> directly. /// Implements <see cref="IDashboardReadService"/> using <see cref="ApplicationDbContext"/> directly.
/// All queries require ThenInclude chains or navigation-property predicates (e.g. JobStatus.StatusCode) /// All queries require navigation-property predicates, eager loading, or aggregate projections that
/// that cannot be expressed through the generic <see cref="PowderCoating.Core.Interfaces.IRepository{T}"/>. /// do not fit the generic repository abstraction well.
/// </summary> /// </summary>
public class DashboardReadService : IDashboardReadService public class DashboardReadService : IDashboardReadService
{ {
@@ -21,6 +21,18 @@ public class DashboardReadService : IDashboardReadService
"CANCELLED" "CANCELLED"
]; ];
private static readonly string[] InProgressStatusCodes =
[
"IN_PREPARATION",
"SANDBLASTING",
"MASKING_TAPING",
"CLEANING",
"IN_OVEN",
"COATING",
"CURING",
"QUALITY_CHECK"
];
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
public DashboardReadService(ApplicationDbContext context) public DashboardReadService(ApplicationDbContext context)
@@ -31,190 +43,406 @@ public class DashboardReadService : IDashboardReadService
/// <inheritdoc/> /// <inheritdoc/>
public async Task<DashboardIndexData> GetIndexDataAsync(DateTime today) public async Task<DashboardIndexData> GetIndexDataAsync(DateTime today)
{ {
var startOfMonth = new DateTime(today.Year, today.Month, 1);
var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1);
var tomorrow = today.AddDays(1); var 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 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 }; var openInvoiceStatuses = new[] { InvoiceStatus.Sent, InvoiceStatus.PartiallyPaid, InvoiceStatus.Overdue };
// All active jobs (for today/overdue/in-progress panels) var activeJobsBase = _context.Jobs
var activeJobs = await _context.Jobs
.AsNoTracking() .AsNoTracking()
.Include(j => j.Customer) .Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode));
.Include(j => j.AssignedUser)
.Include(j => j.JobStatus) var todaysJobsFilter = activeJobsBase.Where(j =>
.Include(j => j.JobPriority) (j.ScheduledDate.HasValue && j.ScheduledDate.Value >= today && j.ScheduledDate.Value < tomorrow) ||
.Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)) (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(); .ToListAsync();
// Monthly revenue — sum completed jobs updated in current month var overdueJobs = await WithDashboardJobIncludes(overdueJobsFilter)
var monthlyRevenue = await _context.Jobs .OrderBy(j => j.JobPriority.DisplayOrder)
.Include(j => j.JobStatus) .ThenBy(j => j.DueDate)
.Where(j => CompletedStatusCodes.Contains(j.JobStatus.StatusCode) .Take(10)
&& j.UpdatedAt >= startOfMonth .ToListAsync();
&& j.UpdatedAt <= endOfMonth)
.SumAsync(j => j.FinalPrice);
// Today's appointments (non-cancelled) var inProgressJobs = await WithDashboardJobIncludes(inProgressJobsFilter)
var todaysAppointments = await _context.Appointments .OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate)
.Take(10)
.ToListAsync();
var todaysAppointmentsBase = _context.Appointments
.AsNoTracking() .AsNoTracking()
.Include(a => a.Customer) .Where(a =>
.Include(a => a.AppointmentType) a.ScheduledStartTime >= today &&
.Include(a => a.AppointmentStatus) a.ScheduledStartTime < tomorrow &&
.Include(a => a.AssignedUser) 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) .OrderBy(a => a.ScheduledStartTime)
.Take(10)
.ToListAsync(); .ToListAsync();
// Upcoming/overdue maintenance var lowStockBase = _context.InventoryItems
var upcomingMaintenance = await _context.MaintenanceRecords
.AsNoTracking() .AsNoTracking()
.Include(m => m.Equipment) .Where(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
.Include(m => m.AssignedUser)
.Where(m => (m.Status == MaintenanceStatus.Scheduled var lowStockCount = await lowStockBase.CountAsync();
|| m.Status == MaintenanceStatus.InProgress var lowStockItems = await lowStockBase
|| m.Status == MaintenanceStatus.Overdue) .OrderBy(i => i.QuantityOnHand)
&& (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAheadDate)) .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) .OrderBy(m => m.Status == MaintenanceStatus.Overdue ? 0 : 1)
.ThenByDescending(m => m.Priority) .ThenByDescending(m => m.Priority)
.ThenBy(m => m.ScheduledDate) .ThenBy(m => m.ScheduledDate)
.Take(10) .Take(10)
.ToListAsync(); .ToListAsync();
// Pending quotes (SENT status) var pendingQuotesBase = _context.Quotes
var pendingQuotes = await _context.Quotes
.AsNoTracking() .AsNoTracking()
.Include(q => q.Customer) .Where(q => q.QuoteStatus.StatusCode == "SENT");
.Include(q => q.QuoteStatus)
.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(); .ToListAsync();
// Open invoices (for AR aging + overdue list) var expiringQuotes = await WithDashboardQuoteIncludes(pendingQuotesBase)
var openInvoices = await _context.Invoices .Where(q => q.ExpirationDate.HasValue &&
.AsNoTracking() q.ExpirationDate.Value >= today &&
.Include(i => i.Customer) q.ExpirationDate.Value < lookAheadInclusive)
.Where(i => openInvoiceStatuses.Contains(i.Status)) .OrderBy(q => q.ExpirationDate)
.ThenBy(q => q.QuoteDate)
.Take(10)
.ToListAsync(); .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 var invoicedThisMonth = await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft .AsNoTracking()
&& i.Status != InvoiceStatus.Voided .Where(i =>
&& i.Status != InvoiceStatus.WrittenOff i.Status != InvoiceStatus.Draft &&
&& i.InvoiceDate >= startOfMonth i.Status != InvoiceStatus.Voided &&
&& i.InvoiceDate <= endOfMonth) i.Status != InvoiceStatus.WrittenOff &&
.SumAsync(i => i.Total); i.InvoiceDate >= startOfMonth &&
i.InvoiceDate < startOfNextMonth)
.Select(i => (decimal?)i.Total)
.SumAsync() ?? 0m;
// Collected this month
var collectedThisMonth = await _context.Payments var collectedThisMonth = await _context.Payments
.Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate <= endOfMonth) .AsNoTracking()
.SumAsync(p => p.Amount); .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 var recentPayments = await _context.Payments
.AsNoTracking() .AsNoTracking()
.Include(p => p.Invoice).ThenInclude(i => i!.Customer) .Include(p => p.Invoice)
.ThenInclude(i => i!.Customer)
.OrderByDescending(p => p.PaymentDate) .OrderByDescending(p => p.PaymentDate)
.Take(6) .Take(6)
.ToListAsync(); .ToListAsync();
// Recent quotes (last 30 days) var equipmentAlerts = await _context.Equipment
var recentQuotes = await _context.Quotes
.AsNoTracking() .AsNoTracking()
.Include(q => q.Customer) .Where(e =>
.Include(q => q.QuoteStatus) e.Status == EquipmentStatus.NeedsMaintenance ||
.Where(q => q.CreatedAt >= last30Days) 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) .OrderByDescending(q => q.CreatedAt)
.Take(5) .Take(5)
.ToListAsync(); .ToListAsync();
// Recent jobs (last 30 days) var recentJobs = await WithDashboardJobIncludes(_context.Jobs
var recentJobs = await _context.Jobs
.AsNoTracking() .AsNoTracking()
.Include(j => j.Customer) .Where(j => j.CreatedAt >= last30Days))
.Include(j => j.JobStatus)
.Where(j => j.CreatedAt >= last30Days)
.OrderByDescending(j => j.CreatedAt) .OrderByDescending(j => j.CreatedAt)
.Take(5) .Take(5)
.ToListAsync(); .ToListAsync();
// Jobs needing powder (not yet ordered, insufficient stock) var powderOrdersNeeded = await BuildPowderOrderQuery(orderedOnly: false).ToListAsync();
var jobsNeedingPowder = await _context.Jobs var powderOrdersPlaced = await BuildPowderOrderQuery(orderedOnly: true).ToListAsync();
.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();
// Jobs with powder already ordered but not yet received var billsDueBase = _context.Bills
var jobsWithOrderedPowder = await _context.Jobs
.AsNoTracking() .AsNoTracking()
.Include(j => j.Customer) .Where(b =>
.Include(j => j.JobStatus) (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid) &&
.Include(j => j.JobItems) b.Total > b.AmountPaid);
.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();
// Bills due (open/partial, balance remaining) var billsDueCount = await billsDueBase.CountAsync();
var billsDue = await _context.Bills var billsDueAmount = await billsDueBase
.AsNoTracking() .Select(b => (decimal?)(b.Total - b.AmountPaid))
.SumAsync() ?? 0m;
var billsDue = await billsDueBase
.Include(b => b.Vendor) .Include(b => b.Vendor)
.Where(b => (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid)
&& b.Total > b.AmountPaid)
.OrderBy(b => b.DueDate) .OrderBy(b => b.DueDate)
.Take(15) .Take(15)
.ToListAsync(); .ToListAsync();
// Random tip of the day var tipCount = await _context.DashboardTips
var tips = await _context.DashboardTips.Where(t => t.IsActive).ToListAsync(); .AsNoTracking()
var tipOfTheDay = tips.Count > 0 ? tips[Random.Shared.Next(tips.Count)].TipText : null; .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( return new DashboardIndexData(
ActiveJobs: activeJobs, ActiveJobsCount: activeJobsCount,
MonthlyRevenue: monthlyRevenue, TodaysJobsCount: todaysJobsCount,
TodaysJobs: todaysJobs,
OverdueJobsCount: overdueJobsCount,
OverdueJobs: overdueJobs,
InProgressJobs: inProgressJobs,
TodaysAppointmentsCount: todaysAppointmentsCount,
TodaysAppointments: todaysAppointments, TodaysAppointments: todaysAppointments,
LowStockCount: lowStockCount,
LowStockItems: lowStockItems,
PendingMaintenanceCount: pendingMaintenanceCount,
UpcomingMaintenance: upcomingMaintenance, UpcomingMaintenance: upcomingMaintenance,
PendingQuotesCount: pendingQuotesCount,
PendingQuoteValue: pendingQuoteValue,
PendingQuotes: pendingQuotes, PendingQuotes: pendingQuotes,
OpenInvoices: openInvoices, ExpiringQuotes: expiringQuotes,
ActiveCustomersCount: activeCustomersCount,
MonthlyRevenue: monthlyRevenue,
OutstandingAr: outstandingAr,
InvoicedThisMonth: invoicedThisMonth, InvoicedThisMonth: invoicedThisMonth,
CollectedThisMonth: collectedThisMonth, CollectedThisMonth: collectedThisMonth,
OverdueInvoicesCount: overdueInvoicesCount,
OverdueInvoicesAmount: overdueInvoicesAmount,
ArAging: aging,
OverdueInvoices: overdueInvoices,
RecentPayments: recentPayments, RecentPayments: recentPayments,
RecentQuotes: recentQuotes, RecentQuotes: recentQuotes,
RecentJobs: recentJobs, RecentJobs: recentJobs,
JobsNeedingPowder: jobsNeedingPowder, EquipmentAlerts: equipmentAlerts,
JobsWithOrderedPowder: jobsWithOrderedPowder, PowderOrdersNeeded: powderOrdersNeeded,
PowderOrdersPlaced: powderOrdersPlaced,
BillsDueCount: billsDueCount,
BillsDueAmount: billsDueAmount,
BillsDue: billsDue, BillsDue: billsDue,
TipOfTheDay: tipOfTheDay TipOfTheDay: tipOfTheDay
); );
} }
/// <inheritdoc/>
public async Task<SuperAdminDashboardData> GetSuperAdminDashboardDataAsync(DateTime today)
{
var companies = _context.Companies
.AsNoTracking()
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted);
var summary = await companies
.GroupBy(_ => 1)
.Select(g => new
{
TotalCompanies = g.Count(),
ActiveCompanies = g.Count(c => c.IsActive),
InactiveCompanies = g.Count(c => !c.IsActive),
ActiveSubscriptions = g.Count(c => c.SubscriptionStatus == SubscriptionStatus.Active),
GracePeriodCount = g.Count(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod),
ExpiredCount = g.Count(c =>
c.SubscriptionStatus == SubscriptionStatus.Expired ||
c.SubscriptionStatus == SubscriptionStatus.Canceled)
})
.FirstOrDefaultAsync();
var totalUsers = await _context.Users
.Where(u => u.CompanyId > 0)
.CountAsync();
var planConfigs = await _context.SubscriptionPlanConfigs
.AsNoTracking()
.IgnoreQueryFilters()
.Where(c => c.IsActive)
.OrderBy(c => c.SortOrder)
.Select(c => new { c.Plan, c.DisplayName })
.ToListAsync();
var planLookup = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName);
string PlanName(int plan) => planLookup.TryGetValue(plan, out var name) ? name : plan.ToString();
var planCounts = await companies
.GroupBy(c => c.SubscriptionPlan)
.Select(g => new { Plan = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Plan, x => x.Count);
var companyAlerts = await companies
.Where(c => c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value.Date < today)
.OrderBy(c => c.SubscriptionEndDate)
.Take(20)
.Select(c => new
{
c.Id,
c.CompanyName,
c.SubscriptionPlan,
c.SubscriptionStatus,
c.SubscriptionEndDate,
c.IsActive
})
.ToListAsync();
var recentCompanies = await companies
.OrderByDescending(c => c.CreatedAt)
.Take(10)
.Select(c => new
{
c.Id,
c.CompanyName,
c.SubscriptionPlan,
c.SubscriptionStatus,
c.IsActive,
c.CreatedAt
})
.ToListAsync();
var planDistribution = planConfigs.ToDictionary(
c => c.Plan,
c => new DashboardPlanDistributionData(
c.DisplayName,
planCounts.TryGetValue(c.Plan, out var count) ? count : 0));
return new SuperAdminDashboardData(
TotalCompanies: summary?.TotalCompanies ?? 0,
ActiveCompanies: summary?.ActiveCompanies ?? 0,
InactiveCompanies: summary?.InactiveCompanies ?? 0,
TotalUsers: totalUsers,
ActiveSubscriptions: summary?.ActiveSubscriptions ?? 0,
GracePeriodCount: summary?.GracePeriodCount ?? 0,
ExpiredCount: summary?.ExpiredCount ?? 0,
PlanDistribution: planDistribution,
CompanyAlerts: companyAlerts.Select(c => new SuperAdminCompanyAlertData(
c.Id,
c.CompanyName,
c.SubscriptionPlan,
PlanName(c.SubscriptionPlan),
c.SubscriptionStatus,
c.SubscriptionEndDate,
c.SubscriptionEndDate.HasValue ? (int)(today - c.SubscriptionEndDate.Value.Date).TotalDays : 0,
c.IsActive)).ToList(),
RecentCompanies: recentCompanies.Select(c => new SuperAdminRecentCompanyData(
c.Id,
c.CompanyName,
c.SubscriptionPlan,
PlanName(c.SubscriptionPlan),
c.SubscriptionStatus,
c.IsActive,
c.CreatedAt)).ToList()
);
}
/// <inheritdoc/> /// <inheritdoc/>
public async Task<int> GetTotalUserCountAsync() public async Task<int> GetTotalUserCountAsync()
{ {
@@ -222,4 +450,76 @@ public class DashboardReadService : IDashboardReadService
.Where(u => u.CompanyId > 0) .Where(u => u.CompanyId > 0)
.CountAsync(); .CountAsync();
} }
private static IQueryable<Job> WithDashboardJobIncludes(IQueryable<Job> query) =>
query.Include(j => j.Customer)
.Include(j => j.AssignedUser)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority);
private static IQueryable<Appointment> WithDashboardAppointmentIncludes(IQueryable<Appointment> query) =>
query.Include(a => a.Customer)
.Include(a => a.AppointmentType)
.Include(a => a.AppointmentStatus)
.Include(a => a.AssignedUser);
private static IQueryable<MaintenanceRecord> WithDashboardMaintenanceIncludes(IQueryable<MaintenanceRecord> query) =>
query.Include(m => m.Equipment)
.Include(m => m.AssignedUser);
private static IQueryable<Quote> WithDashboardQuoteIncludes(IQueryable<Quote> query) =>
query.Include(q => q.Customer)
.Include(q => q.QuoteStatus);
private IQueryable<DashboardPowderOrderLineData> BuildPowderOrderQuery(bool orderedOnly)
{
var coats = _context.JobItemCoats
.AsNoTracking()
.Where(c =>
!c.IsDeleted &&
!c.JobItem.IsDeleted &&
!c.JobItem.Job.IsDeleted &&
!CompletedStatusCodes.Contains(c.JobItem.Job.JobStatus.StatusCode));
coats = orderedOnly
? coats.Where(c => c.PowderOrdered && !c.PowderReceived)
: coats.Where(c =>
!c.PowderOrdered &&
c.PowderToOrder > 0 &&
(c.InventoryItemId == null || (c.InventoryItem != null && c.InventoryItem.QuantityOnHand < c.PowderToOrder)));
return coats.Select(c => new DashboardPowderOrderLineData(
c.Id,
c.JobItem.JobId,
c.JobItem.Job.JobNumber,
c.JobItem.Job.Customer != null
? (c.JobItem.Job.Customer.CompanyName ?? c.JobItem.Job.Customer.ContactFirstName ?? "Unknown")
: "Unknown",
c.CoatName,
c.ColorName ?? (c.InventoryItem != null ? c.InventoryItem.ColorName : null),
c.ColorCode ?? (c.InventoryItem != null ? c.InventoryItem.ColorCode : null),
c.Finish ?? (c.InventoryItem != null ? c.InventoryItem.Finish : null),
c.InventoryItem != null ? c.InventoryItem.SKU : null,
c.PowderToOrder ?? 0m,
c.PowderCostPerLb ?? (c.InventoryItem != null ? c.InventoryItem.UnitCost : null),
orderedOnly ? c.PowderOrderedAt : null,
c.InventoryItemId.HasValue,
c.VendorId ?? (c.InventoryItem != null ? c.InventoryItem.PrimaryVendorId : null),
c.Vendor != null
? c.Vendor.CompanyName
: c.InventoryItem != null && c.InventoryItem.PrimaryVendor != null
? c.InventoryItem.PrimaryVendor.CompanyName
: null,
c.Vendor != null
? c.Vendor.Phone
: c.InventoryItem != null && c.InventoryItem.PrimaryVendor != null
? c.InventoryItem.PrimaryVendor.Phone
: null,
c.Vendor != null
? c.Vendor.Email
: c.InventoryItem != null && c.InventoryItem.PrimaryVendor != null
? c.InventoryItem.PrimaryVendor.Email
: null
));
}
} }
@@ -26,26 +26,6 @@ public class DashboardController : Controller
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly ISubscriptionService _subscriptionService; 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( public DashboardController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
ILogger<DashboardController> logger, ILogger<DashboardController> logger,
@@ -79,48 +59,27 @@ public class DashboardController : Controller
try try
{ {
var today = DateTime.Today; var today = DateTime.Today;
var lookAheadDate = today.AddDays(7);
var data = await _dashboardRead.GetIndexDataAsync(today); var data = await _dashboardRead.GetIndexDataAsync(today);
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Job panels — in-memory split of the pre-fetched activeJobs list // Job panels
// --------------------------------------------------------------- // ---------------------------------------------------------------
var todaysJobsFiltered = data.ActiveJobs var todaysJobs = data.TodaysJobs
.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)
.Select(MapJobDto) .Select(MapJobDto)
.ToList(); .ToList();
var overdueJobsFiltered = data.ActiveJobs var overdueJobs = data.OverdueJobs
.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)
.Select(MapJobDto) .Select(MapJobDto)
.ToList(); .ToList();
var inProgressJobs = data.ActiveJobs var inProgressJobs = data.InProgressJobs
.Where(j => InProgressStatusCodes.Contains(j.JobStatus.StatusCode))
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate)
.Take(10)
.Select(MapJobDto) .Select(MapJobDto)
.ToList(); .ToList();
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Appointments // Appointments
// --------------------------------------------------------------- // ---------------------------------------------------------------
var todaysAppointmentsCount = data.TodaysAppointments.Count;
var todaysAppointments = data.TodaysAppointments var todaysAppointments = data.TodaysAppointments
.Take(10)
.Select(a => new DashboardAppointmentDto .Select(a => new DashboardAppointmentDto
{ {
Id = a.Id, Id = a.Id,
@@ -140,12 +99,7 @@ public class DashboardController : Controller
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Low stock items // Low stock items
// --------------------------------------------------------------- // ---------------------------------------------------------------
var lowStockAll = await _unitOfWork.InventoryItems.FindAsync( var lowStockItems = data.LowStockItems
i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
var lowStockCount = lowStockAll.Count();
var lowStockItems = lowStockAll
.OrderBy(i => i.QuantityOnHand)
.Take(10)
.Select(i => new DashboardLowStockDto .Select(i => new DashboardLowStockDto
{ {
Id = i.Id, Id = i.Id,
@@ -177,8 +131,6 @@ public class DashboardController : Controller
// Quotes // Quotes
// --------------------------------------------------------------- // ---------------------------------------------------------------
var pendingQuotes = data.PendingQuotes var pendingQuotes = data.PendingQuotes
.OrderBy(q => q.ExpirationDate)
.Take(10)
.Select(q => new DashboardQuoteDto .Select(q => new DashboardQuoteDto
{ {
Id = q.Id, Id = q.Id,
@@ -195,14 +147,7 @@ public class DashboardController : Controller
StatusDisplayName = q.QuoteStatus.DisplayName StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList(); }).ToList();
var pendingQuoteValue = data.PendingQuotes.Sum(q => q.Total); var expiringQuotes = data.ExpiringQuotes
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)
.Select(q => new DashboardQuoteDto .Select(q => new DashboardQuoteDto
{ {
Id = q.Id, Id = q.Id,
@@ -219,26 +164,10 @@ public class DashboardController : Controller
StatusDisplayName = q.QuoteStatus.DisplayName StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList(); }).ToList();
// ---------------------------------------------------------------
// Active customers
// ---------------------------------------------------------------
var activeCustomersCount = await _unitOfWork.Customers.CountAsync(c => c.IsActive);
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Invoices & AR aging // Invoices & AR aging
// --------------------------------------------------------------- // ---------------------------------------------------------------
var outstandingAr = data.OpenInvoices.Sum(i => i.BalanceDue); var overdueInvoices = data.OverdueInvoices
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)
.Select(i => new DashboardInvoiceDto .Select(i => new DashboardInvoiceDto
{ {
Id = i.Id, Id = i.Id,
@@ -252,24 +181,6 @@ public class DashboardController : Controller
}) })
.ToList(); .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 // Payments
// --------------------------------------------------------------- // ---------------------------------------------------------------
@@ -278,7 +189,7 @@ public class DashboardController : Controller
{ {
Id = p.Id, Id = p.Id,
InvoiceId = p.InvoiceId, InvoiceId = p.InvoiceId,
InvoiceNumber = p.Invoice?.InvoiceNumber ?? "", InvoiceNumber = p.Invoice?.InvoiceNumber ?? "-",
CustomerName = p.Invoice?.Customer?.CompanyName CustomerName = p.Invoice?.Customer?.CompanyName
?? $"{p.Invoice?.Customer?.ContactFirstName} {p.Invoice?.Customer?.ContactLastName}".Trim(), ?? $"{p.Invoice?.Customer?.ContactFirstName} {p.Invoice?.Customer?.ContactLastName}".Trim(),
Amount = p.Amount, Amount = p.Amount,
@@ -298,11 +209,7 @@ public class DashboardController : Controller
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Equipment alerts // Equipment alerts
// --------------------------------------------------------------- // ---------------------------------------------------------------
var equipmentAlerts = (await _unitOfWork.Equipment.FindAsync( var equipmentAlerts = data.EquipmentAlerts
e => e.Status == EquipmentStatus.NeedsMaintenance ||
e.Status == EquipmentStatus.OutOfService))
.OrderByDescending(e => e.Status == EquipmentStatus.OutOfService ? 1 : 0)
.Take(5)
.Select(e => new DashboardEquipmentAlertDto .Select(e => new DashboardEquipmentAlertDto
{ {
Id = e.Id, Id = e.Id,
@@ -359,134 +266,12 @@ public class DashboardController : Controller
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Powder orders needed // Powder orders needed
// --------------------------------------------------------------- // ---------------------------------------------------------------
var powderFlat = data.JobsNeedingPowder var powderOrderGroups = MapPowderOrderGroups(data.PowderOrdersNeeded);
.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();
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Powder orders placed // Powder orders placed
// --------------------------------------------------------------- // ---------------------------------------------------------------
var placedFlat = data.JobsWithOrderedPowder var powderPlacedGroups = MapPowderOrderGroups(data.PowderOrdersPlaced);
.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();
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Bills due // Bills due
@@ -495,7 +280,7 @@ public class DashboardController : Controller
{ {
Id = b.Id, Id = b.Id,
BillNumber = b.BillNumber, BillNumber = b.BillNumber,
VendorName = b.Vendor.CompanyName, VendorName = b.Vendor?.CompanyName ?? "Unknown",
BalanceDue = b.BalanceDue, BalanceDue = b.BalanceDue,
DueDate = b.DueDate, DueDate = b.DueDate,
IsOverdue = b.DueDate.HasValue && b.DueDate.Value.Date < today, IsOverdue = b.DueDate.HasValue && b.DueDate.Value.Date < today,
@@ -506,28 +291,28 @@ public class DashboardController : Controller
var vm = new DashboardViewModel var vm = new DashboardViewModel
{ {
// Counts // Counts
ActiveJobsCount = data.ActiveJobs.Count, ActiveJobsCount = data.ActiveJobsCount,
TodaysJobsCount = todaysJobsCount, TodaysJobsCount = data.TodaysJobsCount,
OverdueJobsCount = overdueJobsCount, OverdueJobsCount = data.OverdueJobsCount,
TodaysAppointmentsCount = todaysAppointmentsCount, TodaysAppointmentsCount = data.TodaysAppointmentsCount,
LowStockCount = lowStockCount, LowStockCount = data.LowStockCount,
PendingMaintenanceCount = data.UpcomingMaintenance.Count, PendingMaintenanceCount = data.PendingMaintenanceCount,
PendingQuotesCount = data.PendingQuotes.Count, PendingQuotesCount = data.PendingQuotesCount,
PendingQuoteValue = pendingQuoteValue, PendingQuoteValue = data.PendingQuoteValue,
MonthlyRevenue = data.MonthlyRevenue, MonthlyRevenue = data.MonthlyRevenue,
ActiveCustomersCount = activeCustomersCount, ActiveCustomersCount = data.ActiveCustomersCount,
// Financial KPIs // Financial KPIs
OutstandingAr = outstandingAr, OutstandingAr = data.OutstandingAr,
CollectedThisMonth = data.CollectedThisMonth, CollectedThisMonth = data.CollectedThisMonth,
InvoicedThisMonth = data.InvoicedThisMonth, InvoicedThisMonth = data.InvoicedThisMonth,
OverdueInvoicesCount = overdueInvoicesCount, OverdueInvoicesCount = data.OverdueInvoicesCount,
OverdueInvoicesAmount = overdueInvoicesAmount, OverdueInvoicesAmount = data.OverdueInvoicesAmount,
AgingCurrent = agingCurrent, AgingCurrent = data.ArAging.Current,
AgingDays1To30 = aging1To30, AgingDays1To30 = data.ArAging.Days1To30,
AgingDays31To60 = aging31To60, AgingDays31To60 = data.ArAging.Days31To60,
AgingDays61To90 = aging61To90, AgingDays61To90 = data.ArAging.Days61To90,
AgingDaysOver90 = agingOver90, AgingDaysOver90 = data.ArAging.DaysOver90,
// Sections // Sections
TodaysJobs = todaysJobs, TodaysJobs = todaysJobs,
@@ -545,14 +330,14 @@ public class DashboardController : Controller
// Bills Due // Bills Due
BillsDue = billsDue, BillsDue = billsDue,
BillsDueCount = billsDue.Count, BillsDueCount = data.BillsDueCount,
BillsDueAmount = billsDue.Sum(b => b.BalanceDue), BillsDueAmount = data.BillsDueAmount,
// Powder orders // Powder orders
PowderOrdersNeeded = powderOrderGroups, PowderOrdersNeeded = powderOrderGroups,
PowderOrdersNeededCount = powderFlat.Count, PowderOrdersNeededCount = data.PowderOrdersNeeded.Count,
PowderOrdersPlaced = powderPlacedGroups, PowderOrdersPlaced = powderPlacedGroups,
PowderOrdersPlacedCount = placedFlat.Count, PowderOrdersPlacedCount = data.PowderOrdersPlaced.Count,
TipOfTheDay = data.TipOfTheDay TipOfTheDay = data.TipOfTheDay
}; };
@@ -767,7 +552,7 @@ public class DashboardController : Controller
DoneSubLabel = "Your payment defaults are locked in.", DoneSubLabel = "Your payment defaults are locked in.",
Icon = "bi-file-earmark-text", Icon = "bi-file-earmark-text",
CtaText = "Set payment terms", 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 try
{ {
var today = DateTime.Today; var today = DateTime.Today;
var data = await _dashboardRead.GetSuperAdminDashboardDataAsync(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 vm = new SuperAdminDashboardViewModel var vm = new SuperAdminDashboardViewModel
{ {
TotalCompanies = companies.Count, TotalCompanies = data.TotalCompanies,
ActiveCompanies = companies.Count(c => c.IsActive), ActiveCompanies = data.ActiveCompanies,
InactiveCompanies = companies.Count(c => !c.IsActive), InactiveCompanies = data.InactiveCompanies,
TotalUsers = totalUsers, TotalUsers = data.TotalUsers,
PlanDistribution = planDistribution, PlanDistribution = data.PlanDistribution.ToDictionary(
ActiveSubscriptions = companies.Count(c => c.SubscriptionStatus == SubscriptionStatus.Active), kvp => kvp.Key,
GracePeriodCount = companies.Count(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod), kvp => (kvp.Value.DisplayName, kvp.Value.Count)),
ExpiredCount = companies.Count(c => ActiveSubscriptions = data.ActiveSubscriptions,
c.SubscriptionStatus == SubscriptionStatus.Expired || GracePeriodCount = data.GracePeriodCount,
c.SubscriptionStatus == SubscriptionStatus.Canceled), ExpiredCount = data.ExpiredCount,
CompanyAlerts = companyAlerts, CompanyAlerts = data.CompanyAlerts.Select(c => new PlatformCompanyAlertDto
RecentCompanies = recentCompanies {
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); return View(vm);
@@ -1122,6 +872,46 @@ public class DashboardController : Controller
} }
} }
private static List<PowderOrderVendorGroupDto> MapPowderOrderGroups(
IEnumerable<DashboardPowderOrderLineData> lines) =>
lines.GroupBy(l => l.VendorId)
.Select(g =>
{
var first = g.First();
return new PowderOrderVendorGroupDto
{
VendorId = g.Key,
VendorName = first.VendorName ?? "No Vendor Assigned",
VendorPhone = first.VendorPhone,
VendorEmail = first.VendorEmail,
TotalLbsNeeded = g.Sum(l => l.LbsToOrder),
TotalEstCost = g.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0m),
Lines = g.Select(l => new PowderOrderLineDto
{
CoatId = l.CoatId,
JobId = l.JobId,
JobNumber = l.JobNumber,
CustomerName = l.CustomerName,
CoatName = l.CoatName,
ColorName = l.ColorName,
ColorCode = l.ColorCode,
Finish = l.Finish,
SKU = l.SKU,
LbsToOrder = l.LbsToOrder,
CostPerLb = l.CostPerLb,
OrderedAt = l.OrderedAt,
HasInventoryItem = l.HasInventoryItem,
VendorId = l.VendorId
})
.OrderBy(l => l.OrderedAt ?? DateTime.MinValue)
.ThenBy(l => l.JobNumber)
.ThenBy(l => l.CoatName)
.ToList()
};
})
.OrderBy(g => g.VendorName)
.ToList();
/// <summary> /// <summary>
/// Projects a <see cref="Core.Entities.Job"/> into a lightweight <see cref="DashboardJobDto"/> /// Projects a <see cref="Core.Entities.Job"/> into a lightweight <see cref="DashboardJobDto"/>
/// for use in dashboard job lists. Centralising the mapping in one static helper ensures that /// for use in dashboard job lists. Centralising the mapping in one static helper ensures that
@@ -1,4 +1,5 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.Extensions.Caching.Memory;
using PowderCoating.Web.Services; using PowderCoating.Web.Services;
namespace PowderCoating.Web.Middleware; namespace PowderCoating.Web.Middleware;
@@ -10,22 +11,18 @@ namespace PowderCoating.Web.Middleware;
public class OnlineUserMiddleware public class OnlineUserMiddleware
{ {
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
private readonly IMemoryCache _cache;
/// <summary>
/// Maps each user ID to the UTC time the tracker was last updated.
/// A <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>
/// is used because multiple concurrent requests from the same user (e.g. polling
/// and a page navigation) may race to update the entry. The dictionary is
/// static so the throttle window persists across DI scope lifetimes — the
/// middleware instance itself may be recreated but the throttle state must not.
/// </summary>
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _throttle = new();
/// <summary> /// <summary>
/// Initialises the middleware with the next request delegate in the pipeline. /// Initialises the middleware with the next request delegate in the pipeline.
/// </summary> /// </summary>
/// <param name="next">The next middleware component.</param> /// <param name="next">The next middleware component.</param>
public OnlineUserMiddleware(RequestDelegate next) => _next = next; /// <param name="cache">Shared app cache used for expiring per-user throttle entries.</param>
public OnlineUserMiddleware(RequestDelegate next, IMemoryCache cache)
{
_next = next;
_cache = cache;
}
/// <summary> /// <summary>
/// Calls the downstream pipeline first, then — after the response is /// Calls the downstream pipeline first, then — after the response is
@@ -63,12 +60,15 @@ public class OnlineUserMiddleware
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return; 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; 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; return;
_throttle[userId] = now; _cache.Set(throttleKey, true, TimeSpan.FromSeconds(60));
var email = context.User.FindFirstValue(ClaimTypes.Email) ?? string.Empty; var email = context.User.FindFirstValue(ClaimTypes.Email) ?? string.Empty;
var firstName = context.User.FindFirstValue(ClaimTypes.GivenName) ?? string.Empty; var firstName = context.User.FindFirstValue(ClaimTypes.GivenName) ?? string.Empty;