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 )); } }