initial commit
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
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<ScmMetricDto> DocGrid { get; set; } = new();
|
||||
public List<ScmActivityDto> GlobalFlowLog { get; set; } = new();
|
||||
public List<ScmMetricDto> TopProductsByQty { get; set; } = new();
|
||||
public List<ScmMetricDto> CustGroupStats { get; set; } = new();
|
||||
public List<ScmMetricDto> VendGroupStats { get; set; } = new();
|
||||
public List<ScmMetricDto> PayMethodStats { get; set; } = new();
|
||||
public List<ScmMetricDto> 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<DealStageDto> DealStages { get; set; } = new();
|
||||
public List<LeadSourceDto> LeadSources { get; set; } = new();
|
||||
public List<ConversionMetricDto> ConversionMetrics { get; set; } = new();
|
||||
public List<SalesActivityDto> SalesActivities { get; set; } = new();
|
||||
public List<RecentDealDto> RecentDeals { get; set; } = new();
|
||||
public List<ActivityFeedDto> ActivityFeeds { get; set; } = new();
|
||||
public List<PipelineSummaryDto> PipelineSummary { get; set; } = new();
|
||||
public List<ChartDataPoint> RevenueTrend { get; set; } = new();
|
||||
public List<PatientDistributionDto> PatientDistribution { get; set; } = new();
|
||||
public List<BookingStageDto> BookingStages { get; set; } = new();
|
||||
public List<RecentBookingDto> RecentBookings { get; set; } = new();
|
||||
public List<AppointmentActivityDto> AppointmentActivities { get; set; } = new();
|
||||
public List<RevenueMetricDto> 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<DashboardScmResponse>;
|
||||
|
||||
public class DashboardHandler : IRequestHandler<GetScmDashboardQuery, DashboardScmResponse>
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
public DashboardHandler(AppDbContext context) => _context = context;
|
||||
|
||||
public async Task<DashboardScmResponse> 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<ScmMetricDto> {
|
||||
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<ConversionMetricDto>
|
||||
{
|
||||
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<PipelineSummaryDto>
|
||||
{
|
||||
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<RevenueMetricDto>
|
||||
{
|
||||
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<ScmMetricDto> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user