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:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user