using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
///
/// Implements using directly.
/// All queries require navigation-property predicates, eager loading, or aggregate projections that
/// do not fit the generic repository abstraction well.
///
public class DashboardReadService : IDashboardReadService
{
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"
];
private readonly ApplicationDbContext _context;
public DashboardReadService(ApplicationDbContext context)
{
_context = context;
}
///
public async Task GetIndexDataAsync(DateTime today)
{
var tomorrow = today.AddDays(1);
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 };
var activeJobsBase = _context.Jobs
.AsNoTracking()
.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();
var overdueJobs = await WithDashboardJobIncludes(overdueJobsFilter)
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.DueDate)
.Take(10)
.ToListAsync();
var inProgressJobs = await WithDashboardJobIncludes(inProgressJobsFilter)
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate)
.Take(10)
.ToListAsync();
var todaysAppointmentsBase = _context.Appointments
.AsNoTracking()
.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();
var lowStockBase = _context.InventoryItems
.AsNoTracking()
.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();
var pendingQuotesBase = _context.Quotes
.AsNoTracking()
.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();
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();
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
.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;
var collectedThisMonth = await _context.Payments
.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();
var recentPayments = await _context.Payments
.AsNoTracking()
.Include(p => p.Invoice)
.ThenInclude(i => i!.Customer)
.OrderByDescending(p => p.PaymentDate)
.Take(6)
.ToListAsync();
var equipmentAlerts = await _context.Equipment
.AsNoTracking()
.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();
var recentJobs = await WithDashboardJobIncludes(_context.Jobs
.AsNoTracking()
.Where(j => j.CreatedAt >= last30Days))
.OrderByDescending(j => j.CreatedAt)
.Take(5)
.ToListAsync();
var powderOrdersNeeded = await BuildPowderOrderQuery(orderedOnly: false).ToListAsync();
var powderOrdersPlaced = await BuildPowderOrderQuery(orderedOnly: true).ToListAsync();
var billsDueBase = _context.Bills
.AsNoTracking()
.Where(b =>
(b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid) &&
b.Total > b.AmountPaid);
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)
.OrderBy(b => b.DueDate)
.Take(15)
.ToListAsync();
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(
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,
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,
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()
{
return await _context.Users
.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
));
}
}