initial commit

This commit is contained in:
2026-07-21 14:35:37 +07:00
commit 0027997ff9
798 changed files with 59083 additions and 0 deletions
+546
View File
@@ -0,0 +1,546 @@
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; }
}
// SWM-specific DTOs
public class BookingKpiDto
{
public string? ResourceName { get; set; }
public int BookingCount { get; set; }
public double WinRate { get; set; }
public string? Color { get; set; }
}
public class BookingStatusDto
{
public string? Source { get; set; }
public int Count { get; set; }
public double Percentage { get; set; }
public string? Color { get; set; }
}
public class CustomerGroupDto
{
public string? Name { get; set; }
public int Count { get; set; }
public double PctValue { get; set; }
public string? BarColor { get; set; }
}
public class VendorGroupDto
{
public string? Name { get; set; }
public int Count { get; set; }
public string? IconClass { get; set; }
}
public class TopProductDto
{
public string? Name { get; set; }
public double Quantity { get; set; }
public double Share { get; set; }
}
public class PaymentMethodDto
{
public string? Name { get; set; }
public int Count { get; set; }
}
public class DocGridDto
{
public string? Label { get; set; }
public string? Value { get; set; }
public string? Color { get; set; }
}
public class CashFlowDto
{
public string? Label { get; set; }
public string? Value { get; set; }
public string? Color { get; set; }
}
public class RecentSoDto
{
public string? Number { get; set; }
public string? Customer { 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 string? Value { get; set; }
public string? IconColorClass { get; set; }
}
public class DealStageDto
{
public string? Stage { get; set; }
public int Count { get; set; }
public string? Color { get; set; }
}
public class SalesActivityDto
{
public string? Category { get; set; }
public int Count { get; set; }
public string? Color { get; set; }
}
public class DashboardSwmResponse
{
// Core KPIs
public int TotalCustomer { get; set; }
public int TotalVendor { get; set; }
public int TotalBookings { get; set; }
public int ConfirmedBookings { get; set; }
public int DraftBookings { get; set; }
public int CancelledBookings { get; set; }
public int TotalResources { get; set; }
public int TotalBookingGroups { get; set; }
public double BookingWinRate { get; set; }
public int PendingSO { get; set; }
public int PendingPO { get; set; }
// Financial
public decimal SalesMTD { get; set; }
public decimal PurchaseMTD { get; set; }
public decimal TotalReceivable { get; set; }
public decimal TotalPayable { get; set; }
// Collections
public List<BookingKpiDto> BookingsByResource { get; set; } = new();
public List<BookingKpiDto> BookingsByGroup { get; set; } = new();
public List<BookingStatusDto> BookingStatuses { get; set; } = new();
public List<CustomerGroupDto> CustomerGroups { get; set; } = new();
public List<VendorGroupDto> VendorGroups { get; set; } = new();
public List<TopProductDto> TopProducts { get; set; } = new();
public List<PaymentMethodDto> PaymentMethods { get; set; } = new();
public List<DocGridDto> DocGrid { get; set; } = new();
public List<CashFlowDto> CashFlowMTD { get; set; } = new();
public List<RecentSoDto> RecentSalesOrders { get; set; } = new();
public List<ActivityFeedDto> RecentBookingsFeed { get; set; } = new();
public List<ChartDataPoint> RevenueTrend { get; set; } = new();
public List<ChartDataPoint> MonthlyBookingsTrend { get; set; } = new();
public List<PipelineSummaryDto> PipelineSummary { get; set; } = new();
public List<DealStageDto> BookingStages { get; set; } = new();
public List<SalesActivityDto> BookingActivity { get; set; } = new();
// Formatted
public string FormattedSales => FormatNumber(SalesMTD);
public string FormattedPurchase => FormatNumber(PurchaseMTD);
public string FormattedReceivable => FormatNumber(TotalReceivable);
public string FormattedPayable => FormatNumber(TotalPayable);
private static string FormatNumber(decimal v)
{
if (v >= 1_000_000) return (v / 1_000_000m).ToString("F1") + "M";
if (v >= 1_000) return (v / 1_000m).ToString("F1") + "K";
return v.ToString("N0");
}
}
public record GetSwmDashboardQuery() : IRequest<DashboardSwmResponse>;
public class DashboardHandler : IRequestHandler<GetSwmDashboardQuery, DashboardSwmResponse>
{
private readonly AppDbContext _context;
public DashboardHandler(AppDbContext context) => _context = context;
public async Task<DashboardSwmResponse> Handle(GetSwmDashboardQuery request, CancellationToken ct)
{
var today = DateTime.Today;
var monthStart = new DateTime(today.Year, today.Month, 1);
// 1. Pull top 1000 bookings (with resource)
var topBookings = await _context.Booking
.AsNoTracking()
.OrderByDescending(x => x.StartTime)
.Take(1000)
.Select(x => new
{
x.Status,
x.StartTime,
x.Subject,
x.AutoNumber,
ResourceName = x.BookingResource != null ? x.BookingResource.Name : "N/A",
ResourceId = x.BookingResourceId ?? "0",
})
.ToListAsync(ct);
// 2. Pull top 1000 sales orders (with customer)
var topSO = await _context.SalesOrder
.AsNoTracking()
.Where(x => x.OrderDate != null)
.OrderByDescending(x => x.OrderDate)
.Take(1000)
.Select(x => new
{
x.AutoNumber,
x.OrderDate,
x.AfterTaxAmount,
x.OrderStatus,
CustomerName = x.Customer != null ? x.Customer.Name : "N/A",
})
.ToListAsync(ct);
// 3. Pull top 1000 sales order items (with product)
var topSOItems = await _context.SalesOrderItem
.AsNoTracking()
.Where(x => x.Quantity != null)
.OrderByDescending(x => x.SalesOrder!.OrderDate)
.Take(1000)
.Select(x => new
{
ProductName = x.Product != null ? x.Product.Name : "N/A",
x.Quantity,
})
.ToListAsync(ct);
// 4. Pull all booking resources & groups (small tables, pull all)
var allResources = await _context.BookingResource
.AsNoTracking()
.Select(x => new { x.Name, GroupName = x.BookingGroup != null ? x.BookingGroup.Name : "General" })
.ToListAsync(ct);
// 5. Pull all customers & vendors (with groups)
var allCustomers = await _context.Customer
.AsNoTracking()
.Select(x => new { GroupName = x.CustomerGroup != null ? x.CustomerGroup.Name : "General" })
.ToListAsync(ct);
var allVendors = await _context.Vendor
.AsNoTracking()
.Select(x => new { GroupName = x.VendorGroup != null ? x.VendorGroup.Name : "General" })
.ToListAsync(ct);
// 6. Pull payment methods
var allPayMethods = await _context.PaymentReceive
.AsNoTracking()
.Select(x => new { MethodName = x.PaymentMethod != null ? x.PaymentMethod.Name : "Cash" })
.ToListAsync(ct);
// === AGGREGATE IN MEMORY ===
// Core counts from bookings
var totalBookings = topBookings.Count;
var confirmedBookings = topBookings.Count(x => x.Status == BookingStatus.Confirmed);
var draftBookings = topBookings.Count(x => x.Status == BookingStatus.Draft);
var cancelledBookings = topBookings.Count(x => x.Status == BookingStatus.Cancelled);
var bookingWinRate = totalBookings > 0 ? Math.Round((double)confirmedBookings / totalBookings * 100, 1) : 0;
var totalResources = allResources.Select(x => x.Name).Distinct().Count();
var totalGroups = allResources.Select(x => x.GroupName).Distinct().Count();
var totalCustomer = allCustomers.Count;
var totalVendor = allVendors.Count;
// Financial from topSO (already has dates and amounts)
var salesMTD = topSO.Where(x => x.OrderDate >= monthStart).Sum(x => x.AfterTaxAmount ?? 0);
var purchaseMTD = await _context.PurchaseOrder.AsNoTracking()
.Where(x => x.OrderDate >= monthStart)
.SumAsync(x => x.AfterTaxAmount ?? 0, ct);
var totalReceivable = await _context.Invoice.AsNoTracking()
.Where(x => x.InvoiceStatus == InvoiceStatus.Confirmed)
.SumAsync(x => (x.SalesOrder != null ? x.SalesOrder.AfterTaxAmount : 0) ?? 0, ct);
var totalPayable = await _context.Bill.AsNoTracking()
.Where(x => x.BillStatus == BillStatus.Confirmed)
.SumAsync(x => (x.PurchaseOrder != null ? x.PurchaseOrder.AfterTaxAmount : 0) ?? 0, ct);
var pendingSO = topSO.Count(x => x.OrderStatus == SalesOrderStatus.Draft);
var pendingPO = await _context.PurchaseOrder.AsNoTracking().CountAsync(x => x.OrderStatus == PurchaseOrderStatus.Draft, ct);
// Bookings by Resource (from topBookings)
var whColors = new[] { "#2563eb", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316", "#6366f1" };
var bookingsByResource = topBookings
.GroupBy(x => x.ResourceName)
.Select((g, i) => new BookingKpiDto
{
ResourceName = g.Key,
BookingCount = g.Count(),
WinRate = g.Count() > 0 ? Math.Round((double)g.Count(x => x.Status == BookingStatus.Confirmed) / g.Count() * 100, 1) : 0,
Color = whColors[i % whColors.Length]
})
.OrderByDescending(x => x.BookingCount)
.Take(8)
.ToList();
// Bookings by Group (from allResources)
var bookingsByGroup = allResources
.GroupBy(x => x.GroupName)
.Select((g, i) => new BookingKpiDto
{
ResourceName = g.Key,
BookingCount = g.Count(),
WinRate = 0,
Color = "#8b5cf6"
})
.ToList();
// Booking Statuses (for doughnut chart)
var statusColors = new[] { "#10b981", "#f59e0b", "#e11d48" };
var statusLabels = new[] { "Confirmed", "Draft", "Cancelled" };
var statusCounts = new[] { confirmedBookings, draftBookings, cancelledBookings };
var totalStatus = statusCounts.Sum();
var bookingStatuses = statusLabels.Select((l, i) => new BookingStatusDto
{
Source = l,
Count = statusCounts[i],
Percentage = totalStatus > 0 ? Math.Round((double)statusCounts[i] / totalStatus * 100, 1) : 0,
Color = i < statusColors.Length ? statusColors[i] : "#94a3b8"
}).ToList();
// Customer Groups (with progress bars)
var customerGroups = allCustomers
.GroupBy(x => x.GroupName)
.Select(g => new CustomerGroupDto
{
Name = g.Key,
Count = g.Count(),
PctValue = totalCustomer > 0 ? Math.Min(Math.Round((double)g.Count() / totalCustomer * 100, 1), 100) : 0,
BarColor = "#2563eb"
})
.OrderByDescending(x => x.Count)
.Take(6)
.ToList();
// Vendor Groups
var vendorGroups = allVendors
.GroupBy(x => x.GroupName)
.Select(g => new VendorGroupDto
{
Name = g.Key,
Count = g.Count(),
IconClass = "gc7"
})
.ToList();
// Top Products (from topSOItems)
var totalQty = topSOItems.Sum(x => x.Quantity ?? 0);
var topProducts = topSOItems
.GroupBy(x => x.ProductName)
.Select(g => new { Name = g.Key, Qty = g.Sum(x => (double)(x.Quantity ?? 0)) })
.OrderByDescending(x => x.Qty)
.Take(4)
.Select(x => new TopProductDto
{
Name = x.Name,
Quantity = x.Qty,
Share = totalQty > 0 ? Math.Round(x.Qty / totalQty * 100, 1) : 0
})
.ToList();
// Payment Methods
var paymentMethods = allPayMethods
.GroupBy(x => x.MethodName)
.Select(g => new PaymentMethodDto { Name = g.Key, Count = g.Count() })
.ToList();
// Doc Grid
var docGrid = new List<DocGridDto> {
new() { Label = "Sales Order", Value = await _context.SalesOrder.CountAsync(ct).ContinueWith(t => t.Result.ToString()), Color = "#10b981" },
new() { Label = "Purchase Order", Value = await _context.PurchaseOrder.CountAsync(ct).ContinueWith(t => t.Result.ToString()), Color = "#ef4444" },
new() { Label = "Invoice", Value = await _context.Invoice.CountAsync(ct).ContinueWith(t => t.Result.ToString()), Color = "#14b8a6" },
new() { Label = "Bill", Value = await _context.Bill.CountAsync(ct).ContinueWith(t => t.Result.ToString()), Color = "#f97316" },
new() { Label = "Bookings", Value = totalBookings.ToString(), Color = "#8b5cf6" },
new() { Label = "Resources", Value = totalResources.ToString(), Color = "#6366f1" }
};
// Cash Flow MTD
var cashFlowMTD = new List<CashFlowDto> {
new() { Label = "Inflow (Receive)", Value = await _context.PaymentReceive.Where(x => x.PaymentDate >= monthStart).SumAsync(x => x.PaymentAmount ?? 0, ct).ContinueWith(t => t.Result.ToString("N0")), Color = "#10b981" },
new() { Label = "Outflow (Disburse)", Value = await _context.PaymentDisburse.Where(x => x.PaymentDate >= monthStart).SumAsync(x => x.PaymentAmount ?? 0, ct).ContinueWith(t => t.Result.ToString("N0")), Color = "#ef4444" }
};
// Recent Sales Orders (from topSO)
var recentSalesOrders = topSO
.OrderByDescending(x => x.OrderDate)
.Take(6)
.Select(x => new RecentSoDto
{
Number = x.AutoNumber,
Customer = x.CustomerName,
Value = x.AfterTaxAmount ?? 0,
Stage = x.OrderStatus.ToString(),
Status = x.OrderStatus == SalesOrderStatus.Confirmed ? "Posted" : "Pending",
Date = x.OrderDate.HasValue ? new DateTimeOffset(x.OrderDate.Value, TimeSpan.Zero) : null
})
.ToList();
// Recent Bookings activity feed
var recentBookingsFeed = topBookings
.OrderByDescending(x => x.StartTime)
.Take(3)
.Select((b, i) =>
{
var statusClass = b.Status == BookingStatus.Confirmed ? "chip-green" : b.Status == BookingStatus.Draft ? "chip-amber" : "chip-red";
var avatarBg = b.Status == BookingStatus.Confirmed ? "#10b981" : b.Status == BookingStatus.Draft ? "#f59e0b" : "#e11d48";
var timeAgo = b.StartTime.HasValue
? (DateTime.Now - b.StartTime.Value).TotalMinutes < 60
? $"{(int)(DateTime.Now - b.StartTime.Value).TotalMinutes}m ago"
: (DateTime.Now - b.StartTime.Value).TotalHours < 24
? $"{(int)(DateTime.Now - b.StartTime.Value).TotalHours}h ago"
: $"{(int)(DateTime.Now - b.StartTime.Value).TotalDays}d ago"
: "";
var initials = b.ResourceName.Length > 0 ? b.ResourceName[..Math.Min(2, b.ResourceName.Length)].ToUpper() : "BK";
return new ActivityFeedDto
{
Title = $"{b.Subject} — {b.AutoNumber}",
Detail = $"📍 {b.ResourceName} · {b.StartTime:MMM dd, yyyy}",
TimeAgo = timeAgo,
ChipLabel = b.Status.ToString(),
ChipColor = statusClass,
AvatarBg = avatarBg,
AvatarText = initials
};
}).ToList();
// Revenue Trend (from topSO + PurchaseOrder)
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 poMonthsRaw = await _context.PurchaseOrder.AsNoTracking()
.Where(x => x.OrderDate != null)
.Select(x => x.OrderDate!.Value)
.ToListAsync(ct);
var revenueTrend = last6Months.Select(m =>
{
var salesAmount = topSO
.Where(x => x.OrderDate.HasValue
&& x.OrderDate.Value.Year == m.Year
&& x.OrderDate.Value.Month == m.Month)
.Sum(x => (double)(x.AfterTaxAmount ?? 0));
var purchaseAmount = poMonthsRaw
.Where(d => d.Year == m.Year && d.Month == m.Month)
.Sum(d => (double)0); // Purchase amounts computed below
// Actually compute PO amounts from DB for each month
return new { Label = monthNames[m.Month - 1], SalesAmount = Math.Round(salesAmount / 1000.0, 1), m.Year, m.Month };
}).ToList();
// Compute PO amounts for each of last 6 months
var revenueTrendFinal = new List<ChartDataPoint>();
foreach (var m in last6Months)
{
var ms = new DateTime(m.Year, m.Month, 1);
var me = ms.AddMonths(1);
var sa = topSO
.Where(x => x.OrderDate.HasValue && x.OrderDate.Value.Year == m.Year && x.OrderDate.Value.Month == m.Month)
.Sum(x => (double)(x.AfterTaxAmount ?? 0));
var pa = await _context.PurchaseOrder.AsNoTracking()
.Where(x => x.OrderDate >= ms && x.OrderDate < me)
.SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct);
revenueTrendFinal.Add(new ChartDataPoint
{
Label = monthNames[m.Month - 1],
SalesAmount = Math.Round(sa / 1000.0, 1),
PurchaseAmount = Math.Round(pa / 1000.0, 1)
});
}
// Monthly Bookings Trend
var monthlyBookingsTrend = last6Months.Select(m =>
{
var count = topBookings
.Where(x => x.StartTime.HasValue
&& x.StartTime.Value.Year == m.Year
&& x.StartTime.Value.Month == m.Month)
.Count();
return new ChartDataPoint
{
Label = monthNames[m.Month - 1],
SalesAmount = count,
PurchaseAmount = 0
};
}).ToList();
// Pipeline Summary
var pipelineSummary = new List<PipelineSummaryDto>
{
new() { Label = "Total Bookings", Value = totalBookings.ToString("N0"), IconColorClass = "gc1" },
new() { Label = "Confirmed", Value = confirmedBookings.ToString("N0"), IconColorClass = "gc4" },
new() { Label = "Cancelled", Value = cancelledBookings.ToString("N0"), IconColorClass = "gc10" }
};
// Booking Stages
var bookingStages = new List<DealStageDto>
{
new() { Stage = "Confirmed", Count = confirmedBookings, Color = "#10b981" },
new() { Stage = "Draft", Count = draftBookings, Color = "#f59e0b" },
new() { Stage = "Cancelled", Count = cancelledBookings, Color = "#e11d48" }
};
// Booking Activity
var bookingActivity = new List<SalesActivityDto>
{
new() { Category = "Confirmed", Count = confirmedBookings, Color = "#10b981" },
new() { Category = "Draft", Count = draftBookings, Color = "#e11d48" },
new() { Category = "Cancelled", Count = cancelledBookings, Color = "#e11d48" },
new() { Category = "Total", Count = totalBookings, Color = "#6366f1" }
};
return new DashboardSwmResponse
{
TotalCustomer = totalCustomer,
TotalVendor = totalVendor,
TotalBookings = totalBookings,
ConfirmedBookings = confirmedBookings,
DraftBookings = draftBookings,
CancelledBookings = cancelledBookings,
TotalResources = totalResources,
TotalBookingGroups = totalGroups,
BookingWinRate = bookingWinRate,
PendingSO = pendingSO,
PendingPO = pendingPO,
SalesMTD = salesMTD,
PurchaseMTD = purchaseMTD,
TotalReceivable = totalReceivable,
TotalPayable = totalPayable,
BookingsByResource = bookingsByResource,
BookingsByGroup = bookingsByGroup,
BookingStatuses = bookingStatuses,
CustomerGroups = customerGroups,
VendorGroups = vendorGroups,
TopProducts = topProducts,
PaymentMethods = paymentMethods,
DocGrid = docGrid,
CashFlowMTD = cashFlowMTD,
RecentSalesOrders = recentSalesOrders,
RecentBookingsFeed = recentBookingsFeed,
RevenueTrend = revenueTrendFinal,
MonthlyBookingsTrend = monthlyBookingsTrend,
PipelineSummary = pipelineSummary,
BookingStages = bookingStages,
BookingActivity = bookingActivity
};
}
}