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