using Indotalent.Infrastructure.Database; using Indotalent.Data.Entities; using Indotalent.Data.Enums; using MediatR; using Microsoft.EntityFrameworkCore; namespace Indotalent.Features.Root.Home.Cqrs; public class ChartDataPoint { public string? Label { get; set; } public double SalesAmount { get; set; } public double PurchaseAmount { get; set; } } public class ScmMetricDto { public string? Label { get; set; } public string? Value { get; set; } public string? Color { get; set; } public double Percentage { get; set; } } public class ScmActivityDto { public string? Module { get; set; } public string? Number { get; set; } public string? Partner { get; set; } public double Qty { get; set; } public string? Status { get; set; } public DateTime? Date { get; set; } public string? Description { get; set; } } // CMS-specific DTOs for dashboard3 reference layout public class DealStageDto { public string? Stage { get; set; } public int Count { get; set; } public string? Color { get; set; } } public class LeadSourceDto { public string? Source { get; set; } public int Count { get; set; } public double Percentage { get; set; } public string? Color { get; set; } } public class ConversionMetricDto { public string? Label { get; set; } public double Current { get; set; } public double Target { get; set; } public double PctValue { get; set; } public string? BarColor { get; set; } } public class SalesActivityDto { public string? Category { get; set; } public int Count { get; set; } public string? Color { get; set; } } public class RecentDealDto { public string? DealNumber { get; set; } public string? Description { get; set; } public decimal Value { get; set; } public string? Stage { get; set; } public string? Status { get; set; } public DateTimeOffset? Date { get; set; } } public class ActivityFeedDto { public string? Title { get; set; } public string? Detail { get; set; } public string? TimeAgo { get; set; } public string? ChipLabel { get; set; } public string? ChipColor { get; set; } public string? AvatarBg { get; set; } public string? AvatarText { get; set; } } public class PipelineSummaryDto { public string? Label { get; set; } public decimal Value { get; set; } public string? IconColorClass { get; set; } public string? FormattedValue { get; set; } } public class PatientDistributionDto { public string? Label { get; set; } public string? Value { get; set; } public double PctValue { get; set; } public string? BarColor { get; set; } } public class BookingStageDto { public string? Stage { get; set; } public int Count { get; set; } public string? Color { get; set; } } public class RecentBookingDto { public string? Number { get; set; } public string? Patient { get; set; } public string? Resource { get; set; } public DateTime? Date { get; set; } public string? Status { get; set; } } public class AppointmentActivityDto { public string? Type { get; set; } public string? PatientName { get; set; } public string? Detail { get; set; } public string? TimeAgo { get; set; } public string? ChipLabel { get; set; } public string? ChipColor { get; set; } public string? AvatarBg { get; set; } public string? AvatarText { get; set; } } public class RevenueMetricDto { public string? Label { get; set; } public decimal Value { get; set; } public string? FormattedValue { get; set; } public string? Color { get; set; } } public class DashboardScmResponse { // Original properties (keep backward compatibility) public int PendingSO { get; set; } public int PendingPO { get; set; } public int PendingInvoice { get; set; } public int PendingBill { get; set; } public int TotalCustomer { get; set; } public int TotalVendor { get; set; } public decimal SalesMTD { get; set; } public decimal PurchaseMTD { get; set; } public decimal TotalReceivable { get; set; } public decimal TotalPayable { get; set; } public List DocGrid { get; set; } = new(); public List GlobalFlowLog { get; set; } = new(); public List TopProductsByQty { get; set; } = new(); public List CustGroupStats { get; set; } = new(); public List VendGroupStats { get; set; } = new(); public List PayMethodStats { get; set; } = new(); public List CashFlowMTD { get; set; } = new(); public string FormattedSales => FormatCurrency(SalesMTD); public string FormattedPurchase => FormatCurrency(PurchaseMTD); public string FormattedReceivable => FormatCurrency(TotalReceivable); public string FormattedPayable => FormatCurrency(TotalPayable); // CMS-specific dashboard properties public int TotalPatients { get; set; } public int TotalBookings { get; set; } public int TotalMedicalRecords { get; set; } public int ActiveAppointments { get; set; } public decimal MRR { get; set; } public double BookingCompletionRate { get; set; } public int TotalEmployees { get; set; } public int TotalProducts { get; set; } public decimal AvgBookingValue { get; set; } public double OverallConversionRate { get; set; } public double PatientRetentionRate { get; set; } public double SlaPercentage { get; set; } public double EnterprisePercentage { get; set; } public double TargetCvr { get; set; } public List DealStages { get; set; } = new(); public List LeadSources { get; set; } = new(); public List ConversionMetrics { get; set; } = new(); public List SalesActivities { get; set; } = new(); public List RecentDeals { get; set; } = new(); public List ActivityFeeds { get; set; } = new(); public List PipelineSummary { get; set; } = new(); public List RevenueTrend { get; set; } = new(); public List PatientDistribution { get; set; } = new(); public List BookingStages { get; set; } = new(); public List RecentBookings { get; set; } = new(); public List AppointmentActivities { get; set; } = new(); public List RevenueMetrics { get; set; } = new(); public string FormattedTotalPatients => TotalPatients.ToString("N0"); public string FormattedTotalBookings => TotalBookings.ToString("N0"); public string FormattedMRR => FormatCurrency(MRR); public string FormattedAvgBooking => FormatCurrency(AvgBookingValue); public string FormattedActiveAppointments => ActiveAppointments.ToString("N0"); public string FormattedTotalMedicalRecords => TotalMedicalRecords.ToString("N0"); public static string FormatCurrency(decimal v) { if (v >= 1000000) return (v / 1000000m).ToString("F1") + "M"; if (v >= 1000) return (v / 1000m).ToString("F1") + "K"; return v.ToString("N0"); } } public record GetScmDashboardQuery() : IRequest; public class DashboardHandler : IRequestHandler { private readonly AppDbContext _context; public DashboardHandler(AppDbContext context) => _context = context; public async Task Handle(GetScmDashboardQuery request, CancellationToken ct) { var start = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); // =================================================================== // APPROACH: Take top 1000 raw records → materialize → process in memory // =================================================================== // ---- Patient raw data (top 1000) ---- var patientsRaw = await _context.Patient .OrderByDescending(x => x.CreatedAt) .Take(1000) .Select(x => new { x.Name, GroupName = x.PatientGroup != null ? x.PatientGroup.Name : null, CategoryName = x.PatientCategory != null ? x.PatientCategory.Name : null }) .ToListAsync(ct); var totalPatients = patientsRaw.Count; // Patient distribution by group (processed in memory) var patientGroupStats = patientsRaw .GroupBy(x => string.IsNullOrEmpty(x.GroupName) ? "General" : x.GroupName) .Select(g => new PatientDistributionDto { Label = g.Key, Value = g.Count().ToString(), PctValue = totalPatients > 0 ? Math.Round((double)g.Count() / totalPatients * 100, 1) : 0, BarColor = "#2563eb" }).OrderByDescending(x => x.PctValue).Take(5).ToList(); // Lead Sources / Patient Sources (grouped by category in memory) var patientGroupCounts = patientsRaw .GroupBy(x => string.IsNullOrEmpty(x.CategoryName) ? "General" : x.CategoryName) .Select(g => new { Label = g.Key, Count = g.Count() }) .OrderByDescending(x => x.Count) .Take(5) .ToList(); var totalSourceCount = patientGroupCounts.Sum(x => x.Count); var sourceColors = new[] { "#2563eb", "#f97316", "#ec4899", "#8b5cf6", "#06b6d4" }; var leadSources = patientGroupCounts .Select((g, i) => new LeadSourceDto { Source = g.Label, Count = g.Count, Percentage = totalSourceCount > 0 ? Math.Round((double)g.Count / totalSourceCount * 100, 1) : 0, Color = i < sourceColors.Length ? sourceColors[i] : "#94a3b8" }).ToList(); // ---- Vendor raw data (top 1000) ---- var vendorsRaw = await _context.Vendor .OrderByDescending(x => x.CreatedAt) .Take(1000) .Select(x => new { x.Name, GroupName = x.VendorGroup != null ? x.VendorGroup.Name : null }) .ToListAsync(ct); var vendGroups = vendorsRaw .GroupBy(x => string.IsNullOrEmpty(x.GroupName) ? "General" : x.GroupName) .Select(g => new ScmMetricDto { Label = g.Key, Value = g.Count().ToString() }) .ToList(); // ---- Payment methods raw data (top 1000) ---- var paymentsRaw = await _context.PaymentReceive .OrderByDescending(x => x.CreatedAt) .Take(1000) .Select(x => new { MethodName = x.PaymentMethod != null ? x.PaymentMethod.Name : null }) .ToListAsync(ct); var payMethods = paymentsRaw .GroupBy(x => string.IsNullOrEmpty(x.MethodName) ? "Cash" : x.MethodName) .Select(g => new ScmMetricDto { Label = g.Key, Value = g.Count().ToString() }) .ToList(); // ---- Product raw data (top 1000 sales order items) ---- var soiRaw = await _context.SalesOrderItem .OrderByDescending(x => x.CreatedAt) .Take(1000) .Select(x => new { ProductName = x.Product != null ? x.Product.Name : null, Qty = x.Quantity ?? 0 }) .ToListAsync(ct); var topProds = soiRaw .GroupBy(x => string.IsNullOrEmpty(x.ProductName) ? "N/A" : x.ProductName) .OrderByDescending(g => g.Sum(x => (double)x.Qty)) .Take(10) .Select(g => new ScmMetricDto { Label = g.Key, Value = g.Sum(x => (double)x.Qty).ToString("N0") }) .ToList(); // ---- Global flow log (sales orders, top 1000 → take 20) ---- var soRaw = await _context.SalesOrder .OrderByDescending(x => x.OrderDate) .Take(1000) .Select(x => new { x.AutoNumber, CustomerName = x.Customer != null ? x.Customer.Name : "N/A", TotalQty = x.SalesOrderItemList.Sum(i => i.Quantity ?? 0), Status = x.OrderStatus.ToString(), x.OrderDate }) .ToListAsync(ct); var globalLogs = soRaw .Take(20) .Select(x => new ScmActivityDto { Module = "Sales Order", Number = x.AutoNumber, Partner = x.CustomerName, Qty = x.TotalQty, Status = x.Status, Date = x.OrderDate }).ToList(); // ---- Doc counts (simple count queries, fine as-is) ---- var docGrid = new List { new() { Label = "Sales Order", Value = (await _context.SalesOrder.CountAsync(ct)).ToString(), Color = "#10b981" }, new() { Label = "Purchase Order", Value = (await _context.PurchaseOrder.CountAsync(ct)).ToString(), Color = "#ef4444" }, new() { Label = "Invoice", Value = (await _context.Invoice.CountAsync(ct)).ToString(), Color = "#14b8a6" }, new() { Label = "Bill", Value = (await _context.Bill.CountAsync(ct)).ToString(), Color = "#f97316" }, new() { Label = "Pay Receive", Value = (await _context.PaymentReceive.CountAsync(ct)).ToString(), Color = "#84cc16" }, new() { Label = "Pay Disburse", Value = (await _context.PaymentDisburse.CountAsync(ct)).ToString(), Color = "#d946ef" } }; // ---- CMS totals ---- var totalBookings = await _context.Booking.CountAsync(ct); var totalMedicalRecords = await _context.MedicalRecord.CountAsync(ct); var activeBookings = await _context.Booking.CountAsync(x => x.Status == BookingStatus.Confirmed, ct); var totalEmployees = await _context.Employee.CountAsync(ct); var totalProducts = await _context.Product.CountAsync(ct); // ---- Booking raw data (top 1000) ---- var bookingsRaw = await _context.Booking .OrderByDescending(x => x.CreatedAt) .Take(1000) .Select(x => new { x.AutoNumber, x.Subject, ResourceName = x.BookingResource != null ? x.BookingResource.Name : "N/A", x.StartTime, Status = x.Status }) .ToListAsync(ct); var completedBookings = bookingsRaw.Count(x => x.Status == BookingStatus.Done); var bookingCompletionRate = totalBookings > 0 ? Math.Round((double)completedBookings / totalBookings * 100, 1) : 0; // Booking stages (processed from raw data in memory) var bookingStages = bookingsRaw .GroupBy(x => x.Status) .Select(g => new BookingStageDto { Stage = g.Key.ToString(), Count = g.Count(), Color = g.Key switch { BookingStatus.Draft => "#8b5cf6", BookingStatus.Confirmed => "#f59e0b", BookingStatus.OnProgress => "#e11d48", BookingStatus.Done => "#6366f1", _ => "#94a3b8" } }).ToList(); // Recent bookings (top 4 from materialized) var recentBookings = bookingsRaw .Take(4) .Select(x => new RecentBookingDto { Number = x.AutoNumber, Patient = x.Subject ?? "N/A", Resource = x.ResourceName, Date = x.StartTime, Status = x.Status.ToString() }).ToList(); // Recent deals (from bookings) var recentDeals = recentBookings.Select(b => new RecentDealDto { DealNumber = b.Number, Description = b.Patient, Value = 0, Stage = b.Resource, Status = b.Status == "Done" ? "Posted" : b.Status == "Confirmed" ? "Pending" : "Draft", Date = b.Date }).ToList(); // ---- Medical Record raw data (top 1000) ---- var mrRaw = await _context.MedicalRecord .OrderByDescending(x => x.CreatedAt) .Take(1000) .Select(x => new { x.Description, x.Status, CategoryName = x.MedicalRecordCategory != null ? x.MedicalRecordCategory.Name : null, GroupName = x.MedicalRecordGroup != null ? x.MedicalRecordGroup.Name : null, PatientName = x.Patient != null ? x.Patient.Name : null }) .ToListAsync(ct); // Sales activities (medical records grouped by category in memory) var activityColorsArr = new[] { "#10b981", "#e11d48", "#e11d48", "#6366f1" }; var salesActivities = mrRaw .GroupBy(x => string.IsNullOrEmpty(x.CategoryName) ? "General" : x.CategoryName) .OrderByDescending(g => g.Count()) .Take(4) .Select((g, i) => new SalesActivityDto { Category = g.Key, Count = g.Count(), Color = i < activityColorsArr.Length ? activityColorsArr[i] : "#94a3b8" }) .ToList(); // Appointment activities (top 4 from materialized) var medicalRecords = mrRaw .Take(4) .Select(x => new AppointmentActivityDto { Type = string.IsNullOrEmpty(x.CategoryName) ? "Checkup" : x.CategoryName, PatientName = x.PatientName ?? "Patient", Detail = x.Description ?? "", ChipLabel = x.Status.ToString(), ChipColor = x.Status == MedicalRecordStatus.Completed ? "chip-green" : "chip-indigo", AvatarBg = x.Status == MedicalRecordStatus.Completed ? "#10b981" : "#6366f1", AvatarText = (string.IsNullOrEmpty(x.GroupName) ? "MR" : x.GroupName).Substring(0, Math.Min(string.IsNullOrEmpty(x.GroupName) ? 2 : x.GroupName.Length, 2)).ToUpper() }).ToList(); var activityFeeds = medicalRecords.Select(mr => new ActivityFeedDto { Title = mr.Type ?? "Medical Record", Detail = !string.IsNullOrEmpty(mr.PatientName) ? $"Patient: {mr.PatientName}" : "", TimeAgo = mr.ChipLabel ?? "", ChipLabel = mr.ChipLabel, ChipColor = mr.ChipColor, AvatarBg = mr.AvatarBg, AvatarText = mr.AvatarText }).ToList(); // ---- Revenue Trend: top 1000 SalesOrder + PurchaseOrder, materialize, process in memory ---- var soDatesRaw = await _context.SalesOrder .OrderByDescending(x => x.OrderDate) .Take(1000) .Select(x => new { x.OrderDate, Amount = x.AfterTaxAmount ?? 0 }) .ToListAsync(ct); var poDatesRaw = await _context.PurchaseOrder .OrderByDescending(x => x.OrderDate) .Take(1000) .Select(x => new { x.OrderDate, Amount = x.AfterTaxAmount ?? 0 }) .ToListAsync(ct); var allSalesByMonth = soDatesRaw .Where(x => x.OrderDate.HasValue) .GroupBy(x => new { x.OrderDate!.Value.Year, x.OrderDate.Value.Month }) .OrderByDescending(g => g.Key.Year).ThenByDescending(g => g.Key.Month) .Take(6) .Reverse() .Select(g => new { g.Key.Year, g.Key.Month, Sales = g.Sum(x => (double)x.Amount) }) .ToList(); var allPurchaseByMonth = poDatesRaw .Where(x => x.OrderDate.HasValue) .GroupBy(x => new { x.OrderDate!.Value.Year, x.OrderDate.Value.Month }) .ToList(); var monthlyLabels = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; var revenueTrend = allSalesByMonth .Select(ym => { var purchaseAmount = allPurchaseByMonth .Where(g => g.Key.Year == ym.Year && g.Key.Month == ym.Month) .Sum(g => g.Sum(x => (double)x.Amount)); return new ChartDataPoint { Label = monthlyLabels[ym.Month - 1], SalesAmount = Math.Round(ym.Sales / 1000.0, 1), PurchaseAmount = Math.Round(purchaseAmount / 1000.0, 1) }; }).ToList(); // ---- MRR ---- var mrr = await _context.Invoice .Where(x => x.InvoiceStatus == InvoiceStatus.Confirmed && x.CreatedAt >= start) .SumAsync(x => x.SalesOrder != null ? x.SalesOrder.AfterTaxAmount ?? 0 : 0, ct); // ---- Conversion metrics ---- var invoicesPaid = await _context.Invoice.CountAsync(x => x.InvoiceStatus == InvoiceStatus.FullPaid, ct); var invoicesConfirmed = await _context.Invoice.CountAsync(x => x.InvoiceStatus == InvoiceStatus.Confirmed, ct); var conversionMetrics = new List { new() { Label = "Patients->Booked", Current = activeBookings, Target = totalPatients, PctValue = totalPatients > 0 ? Math.Round((double)activeBookings / totalPatients * 100, 1) : 0, BarColor = "#2563eb" }, new() { Label = "Booked->Done", Current = completedBookings, Target = totalBookings > 0 ? totalBookings : 1, PctValue = totalBookings > 0 ? Math.Round((double)completedBookings / totalBookings * 100, 1) : 0, BarColor = "#f97316" }, new() { Label = "Invoice->Paid", Current = invoicesPaid, Target = invoicesConfirmed, PctValue = invoicesConfirmed > 0 ? Math.Round((double)invoicesPaid / invoicesConfirmed * 100, 1) : 0, BarColor = "#ec4899" }, new() { Label = "Overall CVR", Current = completedBookings, Target = totalPatients, PctValue = totalPatients > 0 ? Math.Round((double)completedBookings / totalPatients * 100, 1) : 0, BarColor = "#10b981" } }; // ---- Pipeline summary ---- var pipelineSummaryList = new List { new() { Label = "Total Bookings", Value = totalBookings, FormattedValue = totalBookings.ToString("N0"), IconColorClass = "gc1" }, new() { Label = "Completed", Value = completedBookings, FormattedValue = completedBookings.ToString("N0"), IconColorClass = "gc10" }, new() { Label = "Avg Booking", Value = 0, FormattedValue = "0", IconColorClass = "gc2" } }; // ---- MTD values ---- var salesMTD = soDatesRaw .Where(x => x.OrderDate >= start) .Sum(x => x.Amount); var totalReceivable = await _context.Invoice .Where(x => x.InvoiceStatus == InvoiceStatus.Confirmed) .SumAsync(x => x.SalesOrder != null ? x.SalesOrder.AfterTaxAmount ?? 0 : 0, ct); // ---- Revenue metrics ---- var arBalance = await _context.Invoice .Where(x => x.InvoiceStatus != InvoiceStatus.FullPaid && x.InvoiceStatus == InvoiceStatus.Confirmed) .SumAsync(x => x.SalesOrder != null ? x.SalesOrder.AfterTaxAmount ?? 0 : 0, ct); var collectionRate = invoicesConfirmed > 0 ? Math.Round((double)invoicesPaid / invoicesConfirmed * 100, 1) : 0; var revenueMetrics = new List { new() { Label = "MTD Revenue", Value = salesMTD, FormattedValue = DashboardScmResponse.FormatCurrency(salesMTD), Color = "#2563eb" }, new() { Label = "Total Receivable", Value = totalReceivable, FormattedValue = DashboardScmResponse.FormatCurrency(totalReceivable), Color = "#f97316" }, new() { Label = "AR Balance", Value = arBalance, FormattedValue = DashboardScmResponse.FormatCurrency(arBalance), Color = "#e11d48" }, new() { Label = "Collection Rate", Value = (decimal)collectionRate, FormattedValue = collectionRate.ToString("F1") + "%", Color = "#10b981" } }; // ---- Computed rates ---- var overallConversionRate = totalPatients > 0 ? Math.Round((double)completedBookings / totalPatients * 100, 1) : 0; var slaPercentage = totalBookings > 0 ? Math.Round((double)completedBookings / totalBookings * 100, 1) : 94.2; var enterprisePercentage = totalPatients > 0 ? Math.Round((double)(await _context.Patient.CountAsync(x => x.PatientCategory != null && x.PatientCategory.Name == "VIP", ct)) / totalPatients * 100, 1) : 35; // ---- Cashflow MTD ---- var cashflowInflow = await _context.PaymentReceive .Where(x => x.PaymentDate >= start) .SumAsync(x => x.PaymentAmount ?? 0, ct); var cashflowOutflow = await _context.PaymentDisburse .Where(x => x.PaymentDate >= start) .SumAsync(x => x.PaymentAmount ?? 0, ct); var res = new DashboardScmResponse { // Original properties PendingSO = await _context.SalesOrder.CountAsync(x => x.OrderStatus == SalesOrderStatus.Draft, ct), PendingPO = await _context.PurchaseOrder.CountAsync(x => x.OrderStatus == PurchaseOrderStatus.Draft, ct), PendingInvoice = await _context.Invoice.CountAsync(x => x.InvoiceStatus == InvoiceStatus.Draft, ct), PendingBill = await _context.Bill.CountAsync(x => x.BillStatus == BillStatus.Draft, ct), TotalCustomer = totalPatients, TotalVendor = await _context.Vendor.CountAsync(ct), SalesMTD = salesMTD, PurchaseMTD = await _context.PurchaseOrder.Where(x => x.OrderDate >= start).SumAsync(x => x.AfterTaxAmount ?? 0, ct), TotalReceivable = totalReceivable, TotalPayable = await _context.Bill.Where(x => x.BillStatus == BillStatus.Confirmed).SumAsync(x => x.PurchaseOrder != null ? x.PurchaseOrder.AfterTaxAmount ?? 0 : 0, ct), DocGrid = docGrid, GlobalFlowLog = globalLogs, TopProductsByQty = topProds, CustGroupStats = patientGroupStats.Select(p => new ScmMetricDto { Label = p.Label, Value = p.Value }).ToList(), VendGroupStats = vendGroups, PayMethodStats = payMethods, CashFlowMTD = new List { new() { Label = "Inflow (Receive)", Value = cashflowInflow.ToString("N0"), Color = "#10b981" }, new() { Label = "Outflow (Disburse)", Value = cashflowOutflow.ToString("N0"), Color = "#ef4444" } }, // CMS-specific properties TotalPatients = totalPatients, TotalBookings = totalBookings, TotalMedicalRecords = totalMedicalRecords, ActiveAppointments = activeBookings, MRR = mrr, BookingCompletionRate = bookingCompletionRate, TotalEmployees = totalEmployees, TotalProducts = totalProducts, AvgBookingValue = 0, OverallConversionRate = overallConversionRate, PatientRetentionRate = 92.4, SlaPercentage = slaPercentage, EnterprisePercentage = enterprisePercentage, TargetCvr = 5.0, DealStages = bookingStages.Select(b => new DealStageDto { Stage = b.Stage, Count = b.Count, Color = b.Color }).ToList(), LeadSources = leadSources, ConversionMetrics = conversionMetrics, SalesActivities = salesActivities, RecentDeals = recentDeals, ActivityFeeds = activityFeeds, PipelineSummary = pipelineSummaryList, RevenueTrend = revenueTrend, PatientDistribution = patientGroupStats, BookingStages = bookingStages, RecentBookings = recentBookings, AppointmentActivities = medicalRecords, RevenueMetrics = revenueMetrics }; return res; } }