using Indotalent.Infrastructure.Database; using Indotalent.Data.Entities; using Indotalent.Data.Enums; using MediatR; using Microsoft.EntityFrameworkCore; namespace Indotalent.Features.Root.Home.Cqrs; // Chart data public class ChartDataPoint { public string? Label { get; set; } public double SalesAmount { get; set; } public double PurchaseAmount { get; set; } } // WMS-specific DTOs public class WarehouseKpiDto { public string? WarehouseName { get; set; } public double StockQty { get; set; } public int SkuCount { get; set; } public decimal InventoryValue { get; set; } public string? Color { get; set; } } public class WmsModuleBreakdownDto { public string? Label { get; set; } public int Count { get; set; } public double Quantity { get; set; } public string? Color { get; set; } } public class WmsPipelineSummaryDto { public string? Label { get; set; } public string? Value { get; set; } public string? IconColorClass { get; set; } } public class WmsConversionMetricDto { 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 WmsRecentTransactionDto { public string? Number { get; set; } public string? Description { get; set; } public string? Module { get; set; } public double Quantity { get; set; } public string? Type { get; set; } public string? Status { get; set; } public DateTimeOffset? Date { get; set; } } public class WmsActivityFeedDto { 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 WmsStockMovementPointDto { public string? Label { get; set; } public double InQty { get; set; } public double OutQty { get; set; } } public class WmsYearlyMovementDto { public string? Category { get; set; } public double Qty { get; set; } public string? Color { get; set; } } public class WmsTopProductDto { public string? ProductName { get; set; } public double TotalMovement { get; set; } public string? Color { get; set; } } public class DashboardWmsResponse { // Core KPIs public double TotalStockQty { get; set; } public decimal InventoryValue { get; set; } public int TotalSku { get; set; } public int WarehouseCount { get; set; } public double StockAccuracy { get; set; } public double OrderFulfillmentRate { get; set; } // Monthly movement public double MonthlyInboundQty { get; set; } public double MonthlyOutboundQty { get; set; } public int MonthlyTransactionCount { get; set; } // Yearly public double YearlyInboundQty { get; set; } public double YearlyOutboundQty { get; set; } // Counts public int PendingInbound { get; set; } public int PendingOutbound { get; set; } public int ScrapCount { get; set; } public int AdjustmentCount { get; set; } public int GrCount { get; set; } public int DoCount { get; set; } public int TransferCount { get; set; } public int StockCountCount { get; set; } // Collections public List WarehouseKpis { get; set; } = new(); public List ModuleBreakdown { get; set; } = new(); public List PipelineSummary { get; set; } = new(); public List ConversionMetrics { get; set; } = new(); public List RecentTransactions { get; set; } = new(); public List ActivityFeeds { get; set; } = new(); public List RevenueTrend { get; set; } = new(); public List StockMovementTrend { get; set; } = new(); public List YearlyMovement { get; set; } = new(); public List TopProducts { get; set; } = new(); // Formatted public string FormattedStock => FormatNumber((decimal)TotalStockQty); public string FormattedInvValue => FormatNumber(InventoryValue); public string FormattedMonthlyIn => FormatNumber((decimal)MonthlyInboundQty); public string FormattedMonthlyOut => FormatNumber((decimal)MonthlyOutboundQty); private static string FormatNumber(decimal v) { if (v >= 1_000_000) return (v / 1_000_000m).ToString("F2") + "M"; if (v >= 1_000) return (v / 1_000m).ToString("F1") + "K"; return v.ToString("N0"); } } public record GetWmsDashboardQuery() : IRequest; public class DashboardHandler : IRequestHandler { private readonly AppDbContext _context; public DashboardHandler(AppDbContext context) => _context = context; public async Task Handle(GetWmsDashboardQuery request, CancellationToken ct) { var today = DateTime.Today; var monthStart = new DateTime(today.Year, today.Month, 1); var yearStart = new DateTime(today.Year, 1, 1); // 1. Top 1000 recent confirmed inventory transactions (with product & warehouse) var topTxs = await _context.InventoryTransaction .AsNoTracking() .Where(x => x.ProductId != null && x.WarehouseId != null && x.Status == InventoryTransactionStatus.Confirmed) .OrderByDescending(x => x.CreatedAt) .Take(1000) .Select(x => new { x.ModuleCode, x.ModuleNumber, x.ModuleName, x.MovementDate, x.Movement, x.TransType, x.Stock, x.CreatedAt, ProductId = x.ProductId!, ProductName = x.Product!.Name, ProductNumber = x.Product!.AutoNumber, UnitPrice = x.Product!.UnitPrice ?? 0, WarehouseId = x.WarehouseId!, WarehouseName = x.Warehouse!.Name }) .ToListAsync(ct); // Fallback: if table is empty, use count-based metrics from entity tables var grCount = await _context.GoodsReceive.AsNoTracking().CountAsync(ct); var doCount = await _context.DeliveryOrder.AsNoTracking().CountAsync(ct); var scrapCount = await _context.Scrapping.AsNoTracking().CountAsync(ct); var adjCount = await _context.PositiveAdjustment.AsNoTracking().CountAsync(ct) + await _context.NegativeAdjustment.AsNoTracking().CountAsync(ct); var transferCount = await _context.TransferIn.AsNoTracking().CountAsync(ct); var stockCountCount = await _context.StockCount.AsNoTracking().CountAsync(ct); var pendingIn = await _context.GoodsReceive.AsNoTracking().CountAsync(x => x.Status == GoodsReceiveStatus.Draft, ct); var pendingOut = await _context.DeliveryOrder.AsNoTracking().CountAsync(x => x.Status == DeliveryOrderStatus.Draft, ct); // 2. Aggregate from top transactions var totalStockQty = topTxs.Sum(x => x.Stock ?? 0); var totalSku = topTxs.Select(x => x.ProductId).Distinct().Count(); var warehouseCount = topTxs.Select(x => x.WarehouseId).Distinct().Count(); var inventoryValue = topTxs.Sum(x => (decimal)(x.Stock ?? 0) * x.UnitPrice); // ModuleCode uses SHORT codes: GR, DO, ADJ+, ADJ-, COUNT, SCRP, TO-IN, TO-OUT, SRN, PRN var inboundModules = new HashSet { "GR", "ADJ+", "TO-IN", "PRN" }; var outboundModules = new HashSet { "DO", "ADJ-", "TO-OUT", "SCRP", "SRN" }; bool IsInbound(string? mc) => mc != null && inboundModules.Contains(mc); bool IsOutbound(string? mc) => mc != null && outboundModules.Contains(mc); var monthlyInbound = topTxs .Where(x => x.MovementDate >= monthStart && IsInbound(x.ModuleCode)) .Sum(x => Math.Abs(x.Movement ?? 0)); var monthlyOutbound = topTxs .Where(x => x.MovementDate >= monthStart && IsOutbound(x.ModuleCode)) .Sum(x => Math.Abs(x.Movement ?? 0)); var monthlyTxCount = topTxs.Count(x => x.MovementDate >= monthStart); var yearlyInbound = topTxs .Where(x => x.MovementDate >= yearStart && IsInbound(x.ModuleCode)) .Sum(x => Math.Abs(x.Movement ?? 0)); var yearlyOutbound = topTxs .Where(x => x.MovementDate >= yearStart && IsOutbound(x.ModuleCode)) .Sum(x => Math.Abs(x.Movement ?? 0)); // 3. Stock Accuracy from StockCount transactions (ModuleCode = "COUNT") var countTxs = topTxs.Where(x => x.ModuleCode == "COUNT").ToList(); var stockAccuracy = 100.0; if (countTxs.Any()) { var totalSys = countTxs.Sum(x => x.Stock ?? 0); var totalDelta = countTxs.Sum(x => Math.Abs((x.Movement ?? 0) - (x.Stock ?? 0))); stockAccuracy = totalSys > 0 ? Math.Max(0, Math.Round(100 - (totalDelta / totalSys * 100), 1)) : 100; } // 4. Order Fulfillment var totalDo = await _context.DeliveryOrder.AsNoTracking().CountAsync(x => x.Status != DeliveryOrderStatus.Draft, ct); var doConfirmed = await _context.DeliveryOrder.AsNoTracking().CountAsync(x => x.Status == DeliveryOrderStatus.Confirmed, ct); var fulfPct = totalDo > 0 ? Math.Round((double)doConfirmed / totalDo * 100, 1) : 0; // 5. Warehouse KPIs (group by warehouse from top txs) var whColors = new[] { "#2563eb", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4" }; var warehouseKpis = topTxs .GroupBy(x => new { x.WarehouseId, x.WarehouseName }) .Select((g, i) => new WarehouseKpiDto { WarehouseName = g.Key.WarehouseName ?? "Unknown", StockQty = g.Sum(x => x.Stock ?? 0), SkuCount = g.Select(x => x.ProductId).Distinct().Count(), InventoryValue = g.Sum(x => (decimal)(x.Stock ?? 0) * x.UnitPrice), Color = whColors[i % whColors.Length] }) .OrderByDescending(x => x.StockQty) .ToList(); // 6. Module breakdown (by count) — ModuleCode uses SHORT codes var moduleColors = new Dictionary { ["GR"] = "#06b6d4", ["DO"] = "#10b981", ["ADJ+"] = "#6366f1", ["ADJ-"] = "#ef4444", ["COUNT"] = "#f59e0b", ["SCRP"] = "#dc2626", ["TO-IN"] = "#8b5cf6", ["TO-OUT"] = "#f43f5e", ["PRN"] = "#0891b2", ["SRN"] = "#ea580c" }; var moduleBreakdown = topTxs .GroupBy(x => x.ModuleCode ?? "Other") .Select(g => new WmsModuleBreakdownDto { Label = g.Key, Count = g.Count(), Quantity = g.Sum(x => Math.Abs(x.Movement ?? 0)), Color = moduleColors.TryGetValue(g.Key, out var c) ? c : "#94a3b8" }) .OrderByDescending(x => x.Count) .ToList(); if (!moduleBreakdown.Any()) { moduleBreakdown = new List { new() { Label = "GR", Count = grCount, Color = "#06b6d4" }, new() { Label = "DO", Count = doCount, Color = "#10b981" }, new() { Label = "ADJ", Count = adjCount, Color = "#6366f1" }, new() { Label = "SCRP", Count = scrapCount, Color = "#dc2626" }, new() { Label = "TO", Count = transferCount, Color = "#8b5cf6" } }; } // 7. Pipeline Summary var pipelineSummary = new List { new() { Label = "Total Stock Qty", Value = FormattedSingle((decimal)totalStockQty), IconColorClass = "gc1" }, new() { Label = "Inventory Value", Value = FormattedSingle(inventoryValue), IconColorClass = "gc2" }, new() { Label = "Active SKUs", Value = totalSku.ToString("N0"), IconColorClass = "gc3" }, new() { Label = "Warehouses", Value = warehouseCount.ToString(), IconColorClass = "gc4" } }; // 8. Conversion Metrics var conversionMetrics = new List { new() { Label = "GR→Stock", Current = grCount, Target = Math.Max(grCount, 1), PctValue = grCount > 0 ? 100 : 0, BarColor = "#06b6d4" }, new() { Label = "DO Fulfilled", Current = doConfirmed, Target = Math.Max(totalDo, 1), PctValue = fulfPct, BarColor = "#10b981" }, new() { Label = "Stock Accuracy", Current = stockAccuracy, Target = 100, PctValue = stockAccuracy, BarColor = "#6366f1" }, new() { Label = "Pending Clear", Current = totalDo - pendingOut, Target = Math.Max(totalDo, 1), PctValue = totalDo > 0 ? (double)(totalDo - pendingOut) / totalDo * 100 : 0, BarColor = "#f59e0b" } }; // 9. Recent Transactions (top 5 from our top-1000 set) var recentTransactions = topTxs .OrderByDescending(x => x.CreatedAt) .Take(5) .Select(x => new WmsRecentTransactionDto { Number = x.ModuleNumber, Description = x.ProductName ?? "?", Module = x.ModuleCode, Quantity = Math.Abs(x.Movement ?? 0), Type = x.TransType?.ToString() ?? "-", Status = "Confirmed", Date = x.CreatedAt }).ToList(); // 10. Activity Feed (from top transactions, most recent 4) — filter to key module types var activityModules = new[] { "GR", "DO", "SCRP", "TO-IN", "COUNT" }; var avatarColors = new[] { "#06b6d4", "#10b981", "#dc2626", "#8b5cf6", "#6366f1" }; var activityFeeds = topTxs .Where(x => x.ModuleCode != null && activityModules.Contains(x.ModuleCode)) .OrderByDescending(x => x.CreatedAt) .Take(4) .Select((x, i) => { var pn = x.ProductName ?? "?"; var mc = x.ModuleCode ?? "?"; var timeAgo = x.CreatedAt.HasValue ? (DateTimeOffset.UtcNow - x.CreatedAt.Value).TotalMinutes < 60 ? $"{(int)(DateTimeOffset.UtcNow - x.CreatedAt.Value).TotalMinutes}m ago" : (DateTimeOffset.UtcNow - x.CreatedAt.Value).TotalHours < 24 ? $"{(int)(DateTimeOffset.UtcNow - x.CreatedAt.Value).TotalHours}h ago" : $"{(int)(DateTimeOffset.UtcNow - x.CreatedAt.Value).TotalDays}d ago" : ""; return new WmsActivityFeedDto { Title = $"{mc} — {pn}", Detail = $"Qty: {Math.Abs(x.Movement ?? 0):N0} · {x.WarehouseName}", TimeAgo = timeAgo, ChipLabel = mc, ChipColor = i % 2 == 0 ? "chip-cyan" : "chip-amber", AvatarBg = avatarColors[i % avatarColors.Length], AvatarText = mc }; }).ToList(); // 11. Stock Movement Trend (last 6 months from top transactions) var monthNames = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; var last6Months = Enumerable.Range(0, 6) .Select(i => today.AddMonths(-5 + i)) .Select(d => new { d.Year, d.Month }) .ToList(); var stockMovementTrend = last6Months.Select(m => { var inQty = topTxs .Where(x => x.MovementDate.HasValue && x.MovementDate.Value.Year == m.Year && x.MovementDate.Value.Month == m.Month && IsInbound(x.ModuleCode)) .Sum(x => Math.Abs(x.Movement ?? 0)); var outQty = topTxs .Where(x => x.MovementDate.HasValue && x.MovementDate.Value.Year == m.Year && x.MovementDate.Value.Month == m.Month && IsOutbound(x.ModuleCode)) .Sum(x => Math.Abs(x.Movement ?? 0)); return new WmsStockMovementPointDto { Label = monthNames[m.Month - 1], InQty = inQty, OutQty = outQty }; }).ToList(); // 12. SO vs PO revenue trend (from SalesOrder/PurchaseOrder tables, as in CRM) var soMonths = await _context.SalesOrder.AsNoTracking() .Where(x => x.OrderDate != null) .Select(x => x.OrderDate!.Value) .ToListAsync(ct); var poMonths = await _context.PurchaseOrder.AsNoTracking() .Where(x => x.OrderDate != null) .Select(x => x.OrderDate!.Value) .ToListAsync(ct); var allDates = soMonths.Concat(poMonths) .Select(d => new { d.Year, d.Month }) .Distinct() .OrderByDescending(x => x.Year).ThenByDescending(x => x.Month) .Take(6) .Reverse() .ToList(); var revenueTrend = new List(); foreach (var ym in allDates) { var ms = new DateTime(ym.Year, ym.Month, 1); var me = ms.AddMonths(1); var sa = await _context.SalesOrder.AsNoTracking() .Where(x => x.OrderDate >= ms && x.OrderDate < me) .SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct); var pa = await _context.PurchaseOrder.AsNoTracking() .Where(x => x.OrderDate >= ms && x.OrderDate < me) .SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct); revenueTrend.Add(new ChartDataPoint { Label = monthNames[ym.Month - 1], SalesAmount = Math.Round(sa / 1000.0, 1), PurchaseAmount = Math.Round(pa / 1000.0, 1) }); } // 13. Yearly Movement summary var yearlyMovement = new List { new() { Category = "Inbound", Qty = yearlyInbound, Color = "#10b981" }, new() { Category = "Outbound", Qty = yearlyOutbound, Color = "#e11d48" }, new() { Category = "Net", Qty = yearlyInbound - yearlyOutbound, Color = "#6366f1" } }; // 14. Top Products by total movement volume var topProducts = topTxs .GroupBy(x => x.ProductName ?? "?") .Select(g => new { Name = g.Key, Total = g.Sum(x => Math.Abs(x.Movement ?? 0)) }) .OrderByDescending(x => x.Total) .Take(5) .Select((x, i) => new WmsTopProductDto { ProductName = x.Name, TotalMovement = x.Total, Color = whColors[i % whColors.Length] }) .ToList(); return new DashboardWmsResponse { TotalStockQty = totalStockQty, InventoryValue = inventoryValue, TotalSku = totalSku, WarehouseCount = warehouseCount, StockAccuracy = stockAccuracy, OrderFulfillmentRate = fulfPct, MonthlyInboundQty = monthlyInbound, MonthlyOutboundQty = monthlyOutbound, MonthlyTransactionCount = monthlyTxCount, YearlyInboundQty = yearlyInbound, YearlyOutboundQty = yearlyOutbound, PendingInbound = pendingIn, PendingOutbound = pendingOut, ScrapCount = scrapCount, AdjustmentCount = adjCount, GrCount = grCount, DoCount = doCount, TransferCount = transferCount, StockCountCount = stockCountCount, WarehouseKpis = warehouseKpis, ModuleBreakdown = moduleBreakdown, PipelineSummary = pipelineSummary, ConversionMetrics = conversionMetrics, RecentTransactions = recentTransactions, ActivityFeeds = activityFeeds, RevenueTrend = revenueTrend, StockMovementTrend = stockMovementTrend, YearlyMovement = yearlyMovement, TopProducts = topProducts }; } private static string FormattedSingle(decimal v) { if (v >= 1_000_000) return (v / 1_000_000m).ToString("F2") + "M"; if (v >= 1_000) return (v / 1_000m).ToString("F1") + "K"; return v.ToString("N0"); } }