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
@@ -0,0 +1,586 @@
@page "/home"
@using Microsoft.AspNetCore.Authorization
@using Indotalent.Infrastructure.Authorization.Identity
@using Indotalent.Features.Root.Home.Cqrs
@using Indotalent.Features.Root.Home
@attribute [Authorize(Roles = $"{ApplicationRoles.Admin},{ApplicationRoles.Member}")]
@inject HomeService HomeService
@inject IJSRuntime JS
<PageTitle>SWM Dashboard</PageTitle>
<div style="font-family: 'Poppins', sans-serif;">
<div class="p-4 md:p-6 space-y-6">
@* HEADER *@
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3" style="outline:none;">
<div>
<h1 class="text-2xl md:text-3xl font-bold text-gray-900" style="outline:none;">SWM Dashboard</h1>
</div>
</div>
@* ROW 1: 12-col KPI layout *@
<div class="grid grid-cols-1 lg:grid-cols-12 gap-4">
@* Key Metrics Card *@
<div class="lg:col-span-5 row-span-2 bg-white rounded-xl border border-gray-100 p-6 flex flex-col justify-between" style="animation: fadeInUp .5s ease forwards; animation-delay: .1s;">
<div>
<div class="flex items-center justify-between mb-4">
<div>
<p class="text-xs text-gray-500 font-semibold uppercase tracking-wide">SWM Overview</p>
<h3 class="text-lg font-bold text-gray-900 mt-0.5">Key Metrics</h3>
</div>
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style="background:#eef2ff;">
<svg class="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
</div>
</div>
<div class="grid grid-cols-2 gap-3 mt-2">
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-xs text-gray-500">MTD Sales</p>
<p class="text-lg font-bold text-gray-900">@_data.FormattedSales</p>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-xs text-gray-500">Total Bookings</p>
<p class="text-lg font-bold text-gray-900">@_data.TotalBookings.ToString("N0")</p>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-xs text-gray-500">Win Rate</p>
<p class="text-lg font-bold text-gray-900">@_data.BookingWinRate%</p>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-xs text-gray-500">Customers</p>
<p class="text-lg font-bold text-gray-900">@_data.TotalCustomer.ToString("N0")</p>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-xs text-gray-400">
<span>AR: <b class="text-gray-700">@_data.FormattedReceivable</b></span>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold" style="background:#eef2ff;color:#4f46e5;">FY @DateTime.Now.Year</span>
</div>
</div>
@* 8 Small KPI Cards (row 1, cols 6-12) *@
<div class="lg:col-span-7 grid grid-cols-2 lg:grid-cols-4 gap-3" style="animation: fadeInUp .5s ease forwards; animation-delay: .2s;">
<div class="kpi-card-sm kpi-b1">
<div class="flex items-start justify-between">
<div><p class="kpi-label">MTD Sales</p><p class="kpi-value">@_data.FormattedSales</p></div>
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></div>
</div>
</div>
<div class="kpi-card-sm kpi-b2">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Receivable</p><p class="kpi-value">@_data.FormattedReceivable</p></div>
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg></div>
</div>
</div>
<div class="kpi-card-sm kpi-b3">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Win Rate</p><p class="kpi-value">@_data.BookingWinRate%</p></div>
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z"/></svg></div>
</div>
</div>
<div class="kpi-card-sm kpi-b4">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Customers</p><p class="kpi-value">@_data.TotalCustomer.ToString("N0")</p></div>
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/></svg></div>
</div>
</div>
</div>
<div class="lg:col-span-7 lg:col-start-6 grid grid-cols-2 lg:grid-cols-4 gap-3" style="animation: fadeInUp .5s ease forwards; animation-delay: .3s;">
<div class="kpi-card-sm kpi-b5">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Total Bookings</p><p class="kpi-value">@_data.TotalBookings.ToString("N0")</p></div>
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg></div>
</div>
</div>
<div class="kpi-card-sm kpi-b6">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Resources</p><p class="kpi-value">@_data.TotalResources.ToString("N0")</p></div>
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg></div>
</div>
</div>
<div class="kpi-card-sm kpi-b7">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Active SO</p><p class="kpi-value">@_data.PendingSO.ToString("N0")</p></div>
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg></div>
</div>
</div>
<div class="kpi-card-sm kpi-b8">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Booking Grps</p><p class="kpi-value">@_data.TotalBookingGroups.ToString("N0")</p></div>
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg></div>
</div>
</div>
</div>
</div>
@* Metrics Matrix *@
<div class="bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .2s;">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">SWM Metrics Matrix</h3><p class="text-xs text-gray-400 mt-0.5">Key indicators across all categories</p></div>
</div>
<div class="grid grid-cols-2 md:grid-cols-5 gap-2">
@{
var mmItems = new[] {
new { Title = "MTD Sales", Sub = _data.FormattedSales, ColorClass = "mm-c1" },
new { Title = "Receivable", Sub = _data.FormattedReceivable, ColorClass = "mm-c2" },
new { Title = "Win Rate", Sub = $"{_data.BookingWinRate}%", ColorClass = "mm-c3" },
new { Title = "Customers", Sub = _data.TotalCustomer.ToString("N0"), ColorClass = "mm-c4" },
new { Title = "Total Bookings", Sub = _data.TotalBookings.ToString("N0"), ColorClass = "mm-c5" },
new { Title = "Resources", Sub = _data.TotalResources.ToString("N0"), ColorClass = "mm-c6" },
new { Title = "Pending SO", Sub = _data.PendingSO.ToString("N0"), ColorClass = "mm-c7" },
new { Title = "Pending PO", Sub = _data.PendingPO.ToString("N0"), ColorClass = "mm-c8" },
new { Title = "Confirmed", Sub = _data.ConfirmedBookings.ToString("N0"), ColorClass = "mm-c9" },
new { Title = "Draft", Sub = _data.DraftBookings.ToString("N0"), ColorClass = "mm-c10" },
new { Title = "Cancelled", Sub = _data.CancelledBookings.ToString("N0"), ColorClass = "mm-c11" },
new { Title = "Vendors", Sub = _data.TotalVendor.ToString("N0"), ColorClass = "mm-c12" },
new { Title = "Groups", Sub = _data.TotalBookingGroups.ToString("N0"), ColorClass = "mm-c13" },
new { Title = "MTD Purchase", Sub = _data.FormattedPurchase, ColorClass = "mm-c14" },
new { Title = "Payable", Sub = _data.FormattedPayable, ColorClass = "mm-c15" },
};
}
@foreach (var item in mmItems)
{
<div class="mm-cell">
<div class="mm-icon @item.ColorClass">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<div><p class="mm-title">@item.Title</p><p class="mm-sub">@item.Sub</p></div>
</div>
}
</div>
</div>
@* ROW 2: Revenue Trend + Booking Status Doughnut *@
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="lg:col-span-2 bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .2s;">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">Revenue Trend</h3><p class="text-xs text-gray-400 mt-0.5">Monthly sales vs purchase</p></div>
<div class="flex items-center gap-3 text-xs">
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm" style="background:#2563eb;"></span> Sales (K)</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm" style="background:#f97316;"></span> Purchase (K)</span>
</div>
</div>
<div class="chart-container" style="height:260px;"><canvas id="plChart"></canvas></div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .3s;">
<h3 class="font-bold text-gray-900 mb-1">Booking Status</h3>
<p class="text-xs text-gray-400 mb-3">By category this period</p>
<div class="flex justify-center"><div class="chart-container" style="height:170px;width:170px;"><canvas id="expenseChart"></canvas></div></div>
<div class="grid grid-cols-2 gap-1 mt-3 text-xs">
@if (_data.BookingStatuses.Any())
{
@foreach (var src in _data.BookingStatuses)
{
<div class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full" style="background:@src.Color"></span> @src.Source <span class="font-semibold ml-auto">@src.Percentage%</span>
</div>
}
}
else
{
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-full" style="background:#10b981"></span> Confirmed <span class="font-semibold ml-auto">0%</span></div>
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-full" style="background:#f59e0b"></span> Draft <span class="font-semibold ml-auto">0%</span></div>
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-full" style="background:#e11d48"></span> Cancelled <span class="font-semibold ml-auto">0%</span></div>
}
</div>
</div>
</div>
@* ROW 3: Pipeline Summary + Booking Stages + Booking Activity *@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .3s;">
<h3 class="font-bold text-gray-900 mb-1">Booking Summary</h3>
<p class="text-xs text-gray-400 mb-3">As of @DateTime.Now.ToString("MMMM dd, yyyy")</p>
<div class="space-y-2.5">
@foreach (var ps in _data.PipelineSummary)
{
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi @ps.IconColorClass">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
</div>
<div class="flex-1"><p class="text-xs text-gray-500">@ps.Label</p><p class="text-sm font-bold text-gray-900">@ps.Value</p></div>
</div>
}
<div class="flex items-center justify-between text-xs pt-2 border-t border-gray-100">
<span>Win Rate</span><span class="font-semibold text-gray-900">@_data.BookingWinRate%</span>
<span>Total</span><span class="font-semibold text-gray-900">@_data.TotalBookings</span>
</div>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .4s;">
<h3 class="font-bold text-gray-900 mb-1">Booking Stages</h3>
<p class="text-xs text-gray-400 mb-3">Breakdown by status</p>
<div class="space-y-2.5">
@foreach (var ds in _data.BookingStages)
{
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi" style="background:@ds.Color">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
</div>
<div class="flex-1"><p class="text-xs text-gray-500">@ds.Stage</p><p class="text-sm font-bold" style="color:@ds.Color">@ds.Count</p></div>
</div>
}
</div>
<div class="mt-2 flex justify-between text-xs"><span>Win Rate: <b>@_data.BookingWinRate%</b></span><span>Active: <b>@_data.ConfirmedBookings</b></span></div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .5s;">
<h3 class="font-bold text-gray-900 mb-1">Booking Activity</h3>
<p class="text-xs text-gray-400 mb-3">Monthly movement</p>
<div class="chart-container" style="height:170px;"><canvas id="cashflowChart"></canvas></div>
<div class="grid grid-cols-3 gap-1 mt-3 text-xs text-center">
@foreach (var sa in _data.BookingActivity.Take(3))
{
<div><span class="font-bold" style="color:@(sa.Category == "Confirmed" ? "#10b981" : "#e11d48")">@sa.Count</span><p class="text-gray-400">@sa.Category</p></div>
}
</div>
</div>
</div>
@* ROW 4: Recent Sales Orders + Recent Bookings *@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .4s;">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">Recent Sales Orders</h3><p class="text-xs text-gray-400 mt-0.5">Latest transactions</p></div>
</div>
<div class="grid-wrap">
<table class="grid-table">
<thead><tr><th>Date</th><th>SO#</th><th>Customer</th><th>Value</th><th>Status</th></tr></thead>
<tbody>
@if (_data.RecentSalesOrders.Any())
{
@foreach (var deal in _data.RecentSalesOrders)
{
<tr>
<td class="text-xs">@(deal.Date?.ToString("MMM dd") ?? "-")</td>
<td class="font-mono text-xs">@deal.Number</td>
<td class="text-xs">@deal.Customer</td>
<td class="font-mono text-xs">@deal.Value.ToString("N0")</td>
<td><span class="chip @(deal.Status == "Posted" ? "chip-green" : "chip-amber")">@deal.Status</span></td>
</tr>
}
}
else
{
<tr><td colspan="5" class="text-xs text-center text-gray-400 py-4">No recent sales orders available</td></tr>
}
</tbody>
</table>
</div>
<div class="mt-3 flex justify-between text-xs">
<span>Pending SO: <b class="text-amber-600">@_data.PendingSO</b></span>
<span>Total SO: <b>@_data.RecentSalesOrders.Count</b></span>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .5s;">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">Recent Bookings</h3><p class="text-xs text-gray-400 mt-0.5">Latest spa & wellness bookings</p></div>
<span class="flex items-center gap-1.5 text-xs"><span class="w-2 h-2 rounded-full bg-indigo-400" style="animation: pulse 2s infinite;"></span> <span class="text-indigo-600 font-medium">Live</span></span>
</div>
<div class="activity-feed divide-y divide-gray-100 pr-1">
@if (_data.RecentBookingsFeed.Any())
{
@foreach (var af in _data.RecentBookingsFeed)
{
<div class="flex gap-3 p-2 rounded-lg hover:bg-gray-50 transition">
<div class="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0" style="background:@af.AvatarBg">@af.AvatarText</div>
<div class="min-w-0 flex-1"><p class="text-sm text-gray-700"><b>@af.Title</b></p><p class="text-xs text-gray-400 mt-0.5">@af.Detail</p><p class="text-xs text-gray-400">@af.TimeAgo</p></div>
<span class="chip @af.ChipColor self-start">@af.ChipLabel</span>
</div>
}
}
else
{
<div class="flex gap-3 p-2"><p class="text-xs text-gray-400">No recent bookings</p></div>
}
</div>
</div>
</div>
@* ROW 5: Customer Groups + Vendor Groups + Cash Flow *@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .4s;">
<h3 class="font-bold text-gray-900 mb-1">Customer Groups</h3>
<p class="text-xs text-gray-400 mb-3">Distribution by category</p>
<div class="space-y-2.5">
@if (_data.CustomerGroups.Any())
{
@foreach (var cg in _data.CustomerGroups)
{
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<div class="flex items-center gap-3">
<div class="gi" style="background:@cg.BarColor">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-center">
<p class="text-xs text-gray-500 font-semibold">@cg.Name</p>
<span class="text-xs font-semibold">@cg.Count cust</span>
</div>
<div class="mt-2 progress-bar"><div class="fill" style="width:@($"{cg.PctValue}%");background:@cg.BarColor;"></div></div>
</div>
</div>
</div>
}
}
else
{
<p class="text-xs text-gray-400">No customer group data available</p>
}
</div>
<div class="mt-3 pt-3 border-t border-gray-100 flex justify-between text-xs">
<span>Total: <b class="text-green-600">@_data.TotalCustomer</b></span>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .5s;">
<h3 class="font-bold text-gray-900 mb-1">Vendor Groups</h3>
<p class="text-xs text-gray-400 mb-3">Partner distribution</p>
<div class="space-y-2.5">
@if (_data.VendorGroups.Any())
{
@foreach (var vg in _data.VendorGroups)
{
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi @vg.IconClass">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
</div>
<div class="flex-1"><p class="text-xs text-gray-500">@vg.Name</p><p class="text-sm font-bold text-gray-900">@vg.Count vend</p></div>
</div>
}
}
else
{
<p class="text-xs text-gray-400">No vendor group data available</p>
}
</div>
<div class="mt-2 flex justify-between text-xs"><span>Total: <b>@_data.TotalVendor</b></span></div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .6s;">
<h3 class="font-bold text-gray-900 mb-1">Cash Flow MTD</h3>
<p class="text-xs text-gray-400 mb-3">@DateTime.Now.ToString("MMMM yyyy")</p>
<div class="space-y-2.5">
@foreach (var cf in _data.CashFlowMTD)
{
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi gc4"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg></div>
<div class="flex-1"><p class="text-xs text-gray-500">@cf.Label</p></div>
<div class="text-right"><span class="text-sm font-bold" style="color:@cf.Color">@cf.Value</span></div>
</div>
}
</div>
<div class="mt-3 pt-3 border-t border-gray-100 flex justify-between text-xs">
<span>Payable: <b class="text-red-500">@_data.FormattedPayable</b></span>
</div>
</div>
</div>
@* ROW 6: Top Products + Payment Methods *@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .5s;">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">Top Products</h3><p class="text-xs text-gray-400 mt-0.5">By quantity sold</p></div>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<div class="grid-wrap">
<table class="grid-table" style="background:transparent;">
<thead><tr><th>Product</th><th>Quantity</th><th>Share</th></tr></thead>
<tbody>
@if (_data.TopProducts.Any())
{
@foreach (var p in _data.TopProducts)
{
<tr>
<td class="text-xs">@p.Name</td>
<td class="font-mono text-xs text-green-600">@p.Quantity.ToString("N0")</td>
<td class="font-mono text-xs">@p.Share%</td>
</tr>
}
}
else
{
<tr><td colspan="3" class="text-xs text-center text-gray-400 py-4">No products available</td></tr>
}
</tbody>
</table>
</div>
</div>
<div class="mt-3 pt-3 border-t border-gray-100 flex justify-between text-xs font-semibold">
<span>Total Products: <span class="text-green-600">@_data.TopProducts.Count</span></span>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .6s;">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">Payment Methods</h3><p class="text-xs text-gray-400 mt-0.5">Transaction distribution</p></div>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<div class="grid-wrap">
<table class="grid-table" style="background:transparent;">
<thead><tr><th>Method</th><th>Count</th><th>Status</th></tr></thead>
<tbody>
@if (_data.PaymentMethods.Any())
{
@foreach (var pm in _data.PaymentMethods)
{
<tr>
<td class="text-xs">@pm.Name</td>
<td class="font-mono text-xs">@pm.Count</td>
<td><span class="chip chip-indigo">Active</span></td>
</tr>
}
}
else
{
<tr><td colspan="3" class="text-xs text-center text-gray-400 py-4">No payment methods available</td></tr>
}
</tbody>
</table>
</div>
</div>
<div class="mt-3 pt-3 border-t border-gray-100 flex justify-between text-xs">
<span>Total Methods: <b class="text-gray-900">@_data.PaymentMethods.Count</b></span>
</div>
</div>
</div>
</div>
</div>
@code {
private DashboardSwmResponse _data = new();
private bool _isLoading = true;
private bool _dataReady = false;
private bool _chartsInitialized = false;
protected override async Task OnInitializedAsync()
{
_isLoading = true;
try
{
var res = await HomeService.GetSwmDashboardStatsAsync();
if (res?.IsSuccess == true && res.Value != null)
{
_data = res.Value;
_dataReady = true;
StateHasChanged();
}
}
finally
{
_isLoading = false;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_dataReady && !_chartsInitialized)
{
_chartsInitialized = true;
await InitCharts();
}
}
private async Task InitCharts()
{
var revenueLabels = _data.RevenueTrend.Any()
? _data.RevenueTrend.Select(x => x.Label).ToArray()
: new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun" };
var revenueData = _data.RevenueTrend.Any()
? _data.RevenueTrend.Select(x => x.SalesAmount).ToArray()
: new double[] { 0, 0, 0, 0, 0, 0 };
var expenseData = _data.RevenueTrend.Any()
? _data.RevenueTrend.Select(x => x.PurchaseAmount).ToArray()
: new double[] { 0, 0, 0, 0, 0, 0 };
var sourceLabels = _data.BookingStatuses.Any()
? _data.BookingStatuses.Select(x => x.Source).ToArray()
: new[] { "Confirmed", "Draft", "Cancelled" };
var sourceData = _data.BookingStatuses.Any()
? _data.BookingStatuses.Select(x => x.Percentage).Cast<object>().ToArray()
: new object[] { 0, 0, 0 };
var sourceColors = _data.BookingStatuses.Any()
? _data.BookingStatuses.Select(x => x.Color).ToArray()
: new[] { "#10b981", "#f59e0b", "#e11d48" };
var activityLabels = _data.BookingActivity.Any()
? _data.BookingActivity.Take(4).Select(x => x.Category).ToArray()
: new[] { "Confirmed", "Draft", "Cancelled", "Total" };
var activityCounts = _data.BookingActivity.Any()
? _data.BookingActivity.Take(4).Select(x => (double)x.Count).ToArray()
: new double[] { 0, 0, 0, 0 };
var activityColors = _data.BookingActivity.Any()
? _data.BookingActivity.Take(4).Select(x => x.Color).ToArray()
: new[] { "#10b981", "#e11d48", "#e11d48", "#6366f1" };
await JS.InvokeVoidAsync("initDashboardCharts",
revenueLabels, revenueData, expenseData,
sourceLabels, sourceData, sourceColors,
activityLabels, activityCounts, activityColors);
}
}
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
@@keyframes fadeInUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.chart-container { position: relative; width: 100%; }
.progress-bar { height: 8px; border-radius: 999px; background: #e2e8f0; overflow: hidden; }
.progress-bar .fill { height: 100%; border-radius: 999px; transition: width .8s ease; }
.grid-table th { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: #64748b; padding: 10px 12px; text-align: left; border-bottom: 1px solid #e2e8f0; }
.grid-table td { padding: 10px 12px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9; }
.grid-table tr:hover td { background: #f8fafc; }
.grid-table { width: 100%; border-collapse: collapse; }
.grid-wrap { max-height: 340px; overflow-y: auto; scrollbar-width: thin; }
.grid-wrap::-webkit-scrollbar { width: 4px; }
.grid-wrap::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
.chip { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 999px; font-size: 10px; font-weight: 600; color: #fff; }
.chip-blue { background: #2563eb; }
.chip-green { background: #10b981; }
.chip-amber { background: #f59e0b; }
.chip-red { background: #e11d48; }
.chip-indigo { background: #6366f1; }
.chip-purple { background: #8b5cf6; }
.chip-pink { background: #ec4899; }
.chip-cyan { background: #06b6d4; }
.chip-orange { background: #f97316; }
.chip-teal { background: #14b8a6; }
.activity-feed { max-height: 320px; overflow-y: auto; scrollbar-width: thin; }
.activity-feed::-webkit-scrollbar { width: 4px; }
.activity-feed::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
.kpi-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: 600; }
.kpi-card-sm { border-radius: 14px; padding: 18px; color: #fff; position: relative; overflow: hidden; min-height: 110px; display: flex; flex-direction: column; justify-content: space-between; }
.kpi-card-sm::after { content: ''; position: absolute; top: -30%; right: -20%; width: 80px; height: 80px; border-radius: 50%; background: rgba(255,255,255,.07); pointer-events: none; }
.kpi-card-sm .kpi-label { font-size: 9.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; opacity: .75; }
.kpi-card-sm .kpi-value { font-size: 20px; font-weight: 800; line-height: 1.1; margin-top: 2px; }
.kpi-card-sm .kpi-delta { font-size: 10px; font-weight: 600; }
.kpi-card-sm .kpi-icon { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,.15); flex-shrink: 0; }
.kpi-card-sm .kpi-icon svg { width: 16px; height: 16px; stroke: #fff; stroke-width: 2; }
.kpi-b1 { background: #2563eb; } .kpi-b2 { background: #10b981; } .kpi-b3 { background: #ec4899; } .kpi-b4 { background: #8b5cf6; }
.kpi-b5 { background: #f59e0b; } .kpi-b6 { background: #06b6d4; } .kpi-b7 { background: #f97316; } .kpi-b8 { background: #14b8a6; }
.mm-cell { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 10px; transition: background .2s; }
.mm-cell:hover { background: #f8fafc; }
.mm-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.mm-icon svg { width: 18px; height: 18px; stroke: #fff; stroke-width: 2; }
.mm-title { font-size: 12px; font-weight: 600; color: #334155; line-height: 1.2; }
.mm-sub { font-size: 10px; color: #94a3b8; margin-top: 1px; }
.mm-c1 { background: #2563eb; } .mm-c2 { background: #10b981; } .mm-c3 { background: #ec4899; } .mm-c4 { background: #8b5cf6; }
.mm-c5 { background: #f59e0b; } .mm-c6 { background: #06b6d4; } .mm-c7 { background: #f97316; } .mm-c8 { background: #14b8a6; }
.mm-c9 { background: #6366f1; } .mm-c10 { background: #e11d48; } .mm-c11 { background: #0891b2; } .mm-c12 { background: #7c3aed; }
.mm-c13 { background: #059669; } .mm-c14 { background: #d97706; } .mm-c15 { background: #db2777; } .mm-c16 { background: #0284c7; }
.mm-c17 { background: #65a30d; } .mm-c18 { background: #9333ea; } .mm-c19 { background: #ea580c; } .mm-c20 { background: #0d9488; }
.gi { width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.gi svg { width: 16px; height: 16px; stroke: #fff; stroke-width: 2; }
.gc1 { background: #2563eb; } .gc2 { background: #10b981; } .gc3 { background: #ec4899; } .gc4 { background: #8b5cf6; }
.gc5 { background: #f59e0b; } .gc6 { background: #06b6d4; } .gc7 { background: #f97316; } .gc8 { background: #14b8a6; }
.gc9 { background: #6366f1; } .gc10 { background: #e11d48; } .gc11 { background: #0891b2; }
</style>
+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
};
}
}
+23
View File
@@ -0,0 +1,23 @@
using Indotalent.ConfigBackEnd.Extensions;
using Indotalent.Features.Root.Home.Cqrs;
using MediatR;
using Microsoft.AspNetCore.Authentication.JwtBearer;
namespace Indotalent.Features.Root.Home;
public static class HomeEndpoint
{
public static void MapHomeEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/dashboard").WithTags("Dashboard")
.RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser());
group.MapGet("/oms-stats", async (IMediator mediator) =>
{
var result = await mediator.Send(new GetSwmDashboardQuery());
return result.ToApiResponse("Dashboard data retrieved successfully");
})
.WithName("GetOmsDashboardStats");
}
}
+28
View File
@@ -0,0 +1,28 @@
using Indotalent.ConfigBackEnd.Interfaces;
using Indotalent.ConfigFrontEnd.Common;
using Indotalent.Features.Root.Home.Cqrs;
using Indotalent.Infrastructure.Authentication.Identity;
using Indotalent.Shared.Models;
using Microsoft.AspNetCore.Components;
using MudBlazor;
using RestSharp;
namespace Indotalent.Features.Root.Home;
public class HomeService : BaseService
{
private readonly RestClient _client;
public HomeService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider)
: base(clientFactory, nav, snackbar, currentUserService, tokenProvider)
{
_client = new RestClient(nav.BaseUri);
}
public async Task<ApiResponse<DashboardSwmResponse>?> GetSwmDashboardStatsAsync()
{
var request = new RestRequest("api/dashboard/oms-stats", Method.Get);
request.AddHeader("Accept", "application/json");
return await ExecuteWithResponseAsync<DashboardSwmResponse>(_client, request);
}
}