initial commit

This commit is contained in:
2026-07-21 14:41:46 +07:00
commit 1bfa3dd1c2
1159 changed files with 88228 additions and 0 deletions
@@ -0,0 +1,603 @@
@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>WMS 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;">WMS 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">WMS 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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</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">Stock on Hand</p>
<p class="text-lg font-bold text-gray-900">@_data.FormattedStock</p>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-xs text-gray-500">Inventory Value</p>
<p class="text-lg font-bold text-gray-900">@_data.FormattedInvValue</p>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-xs text-gray-500">MTD Inbound</p>
<p class="text-lg font-bold text-gray-900">@_data.FormattedMonthlyIn</p>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-xs text-gray-500">MTD Outbound</p>
<p class="text-lg font-bold text-gray-900">@_data.FormattedMonthlyOut</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>SKUs: <b class="text-gray-700">@_data.TotalSku.ToString("N0")</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">Total GR</p><p class="kpi-value">@_data.GrCount</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 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/></svg></div>
</div>
</div>
<div class="kpi-card-sm kpi-b2">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Total DO</p><p class="kpi-value">@_data.DoCount</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 10V3L4 14h7v7l9-11h-7z"/></svg></div>
</div>
</div>
<div class="kpi-card-sm kpi-b3">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Stock Acc</p><p class="kpi-value">@_data.StockAccuracy%</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 class="kpi-card-sm kpi-b4">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Order Fulfill</p><p class="kpi-value">@_data.OrderFulfillmentRate%</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>
<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">Scrapping</p><p class="kpi-value">@_data.ScrapCount</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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg></div>
</div>
</div>
<div class="kpi-card-sm kpi-b6">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Adjustments</p><p class="kpi-value">@_data.AdjustmentCount</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="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></div>
</div>
</div>
<div class="kpi-card-sm kpi-b7">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Pending IN</p><p class="kpi-value">@_data.PendingInbound</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">Pending OUT</p><p class="kpi-value">@_data.PendingOutbound</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">WMS Metrics Matrix</h3><p class="text-xs text-gray-400 mt-0.5">Key indicators across warehouse operations</p></div>
</div>
<div class="grid grid-cols-2 md:grid-cols-5 gap-2">
@{
var mmItems = new[] {
new { Title = "Total GR", Sub = _data.GrCount.ToString(), ColorClass = "mm-c1" },
new { Title = "Total DO", Sub = _data.DoCount.ToString(), ColorClass = "mm-c2" },
new { Title = "Stock on Hand", Sub = _data.FormattedStock, ColorClass = "mm-c3" },
new { Title = "Inventory Value", Sub = _data.FormattedInvValue, ColorClass = "mm-c4" },
new { Title = "MTD Inbound", Sub = _data.FormattedMonthlyIn, ColorClass = "mm-c5" },
new { Title = "MTD Outbound", Sub = _data.FormattedMonthlyOut, ColorClass = "mm-c6" },
new { Title = "Accuracy", Sub = $"{_data.StockAccuracy}%", ColorClass = "mm-c7" },
new { Title = "Fulfillment", Sub = $"{_data.OrderFulfillmentRate}%", ColorClass = "mm-c8" },
new { Title = "Pending IN", Sub = _data.PendingInbound.ToString(), ColorClass = "mm-c9" },
new { Title = "Pending OUT", Sub = _data.PendingOutbound.ToString(), ColorClass = "mm-c10" },
new { Title = "Scrapping", Sub = _data.ScrapCount.ToString(), ColorClass = "mm-c11" },
new { Title = "Adjustments", Sub = _data.AdjustmentCount.ToString(), ColorClass = "mm-c12" },
new { Title = "SKUs", Sub = _data.TotalSku.ToString("N0"), ColorClass = "mm-c13" },
new { Title = "Warehouses", Sub = _data.WarehouseCount.ToString(), ColorClass = "mm-c14" },
new { Title = "Transfers", Sub = _data.TransferCount.ToString(), ColorClass = "mm-c15" },
new { Title = "StockCounts", Sub = _data.StockCountCount.ToString(), ColorClass = "mm-c16" },
new { Title = "MTD Tx", Sub = _data.MonthlyTransactionCount.ToString("N0"), ColorClass = "mm-c17" },
new { Title = "Yearly IN", Sub = ((decimal)_data.YearlyInboundQty).ToString("N0"), ColorClass = "mm-c18" },
new { Title = "Yearly OUT", Sub = ((decimal)_data.YearlyOutboundQty).ToString("N0"), ColorClass = "mm-c19" },
new { Title = "Inv Value", Sub = _data.FormattedInvValue, ColorClass = "mm-c20" }
};
}
@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: Stock Movement Trend + Module Breakdown *@
<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">Stock Movement Trend</h3><p class="text-xs text-gray-400 mt-0.5">Monthly inbound vs outbound</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:#06b6d4;"></span> Inbound</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm" style="background:#10b981;"></span> Outbound</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">Module Breakdown</h3>
<p class="text-xs text-gray-400 mb-3">By transaction count</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.ModuleBreakdown.Any())
{
var mbTotal = _data.ModuleBreakdown.Sum(x => x.Count);
@foreach (var mb in _data.ModuleBreakdown)
{
var pct = mbTotal > 0 ? Math.Round((double)mb.Count / mbTotal * 100) : 0;
<div class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full" style="background:@mb.Color"></span> @mb.Label <span class="font-semibold ml-auto">@pct%</span>
</div>
}
}
else
{
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-full" style="background:#06b6d4"></span> GR <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:#10b981"></span> DO <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:#6366f1"></span> ADJ <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:#dc2626"></span> SCRP <span class="font-semibold ml-auto">0%</span></div>
}
</div>
</div>
</div>
@* ROW 3: Warehouse Summary + Module Breakdown Detail + Yearly Movement *@
<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">Warehouse 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">
@if (_data.PipelineSummary.Any())
{
@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>Accuracy</span><span class="font-semibold text-gray-900">@_data.StockAccuracy%</span>
<span>SKUs</span><span class="font-semibold text-gray-900">@_data.TotalSku.ToString("N0")</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">Module Detail</h3>
<p class="text-xs text-gray-400 mb-3">By transaction count</p>
<div class="grid grid-cols-2 gap-2">
@foreach (var mb in _data.ModuleBreakdown)
{
<div class="flex items-center gap-2 p-2 rounded-lg" style="background:#f8fafc;">
<div class="w-6 h-6 rounded flex items-center justify-center shrink-0" style="background:@mb.Color">
<span class="text-white text-xs font-bold">@(mb.Label?.Length > 3 ? mb.Label[..3] : mb.Label)</span>
</div>
<div class="min-w-0"><p class="text-xs text-gray-500 truncate">@mb.Label</p><p class="text-sm font-bold" style="color:@mb.Color">@mb.Count</p></div>
</div>
}
</div>
<div class="mt-2 flex justify-between text-xs">
<span>Total: <b>@_data.ModuleBreakdown.Sum(x => x.Count)</b></span>
<span>Modules: <b>@_data.ModuleBreakdown.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;">
<h3 class="font-bold text-gray-900 mb-1">Yearly Movement</h3>
<p class="text-xs text-gray-400 mb-3">@DateTime.Now.Year summary</p>
<div class="chart-container" style="height:220px;"><canvas id="cashflowChart"></canvas></div>
<div class="grid grid-cols-3 gap-1 mt-3 text-xs text-center">
@foreach (var ym in _data.YearlyMovement)
{
<div><span class="font-bold" style="color:@ym.Color">@ym.Qty.ToString("N0")</span><p class="text-gray-400">@ym.Category</p></div>
}
</div>
</div>
</div>
@* ROW 4: Recent Transactions + WMS Activity *@
<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 Transactions</h3><p class="text-xs text-gray-400 mt-0.5">Latest movements</p></div>
</div>
<div class="grid-wrap">
<table class="grid-table">
<thead><tr><th>Date</th><th>Tx#</th><th>Product</th><th>Qty</th><th>Module</th></tr></thead>
<tbody>
@if (_data.RecentTransactions.Any())
{
@foreach (var d in _data.RecentTransactions)
{
<tr>
<td class="text-xs">@(d.Date?.ToString("MMM dd") ?? "-")</td>
<td class="font-mono text-xs">@d.Number</td>
<td class="text-xs">@d.Description</td>
<td class="font-mono text-xs">@d.Quantity.ToString("N0")</td>
<td><span class="chip @(d.Module == "GR" ? "chip-cyan" : d.Module == "DO" ? "chip-green" : "chip-indigo")">@d.Module</span></td>
</tr>
}
}
else
{
<tr><td colspan="5" class="text-xs text-center text-gray-400 py-4">No recent transactions available</td></tr>
}
</tbody>
</table>
</div>
<div class="mt-3 flex justify-between text-xs">
<span>MTD Tx: <b class="text-indigo-600">@_data.MonthlyTransactionCount.ToString("N0")</b></span>
<span>Pending: <b>@(_data.PendingInbound + _data.PendingOutbound)</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">WMS Activity</h3><p class="text-xs text-gray-400 mt-0.5">Real-time updates</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.ActivityFeeds.Any())
{
@foreach (var af in _data.ActivityFeeds)
{
<div class="flex gap-3 p-2 rounded-lg hover:bg-gray-50 transition">
<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 activity</p></div>
}
</div>
</div>
</div>
@* ROW 5: Conversion Metrics + Top Products + Warehouse KPIs *@
<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">Conversion Metrics</h3>
<p class="text-xs text-gray-400 mb-3">@DateTime.Now.Year Performance</p>
<div class="space-y-2.5">
@foreach (var cm in _data.ConversionMetrics)
{
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<div class="flex items-center gap-3">
<div class="gi" style="background:@cm.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">@cm.Label</p>
<span class="text-xs font-semibold">@cm.Current / @cm.Target</span>
</div>
<div class="mt-2 progress-bar"><div class="fill" style="width:@($"{cm.PctValue}%");background:@cm.BarColor;"></div></div>
</div>
</div>
</div>
}
</div>
<div class="mt-3 pt-3 border-t border-gray-100 flex justify-between text-xs">
<span>Accuracy: <b class="text-green-600">@_data.StockAccuracy%</b></span>
<span class="text-indigo-600">@(_data.StockAccuracy >= 95 ? "On Track" : "Improving")</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">Top Products</h3>
<p class="text-xs text-gray-400 mb-3">By movement volume</p>
<div class="space-y-2.5">
@if (_data.TopProducts.Any())
{
var tpColors = new[] { "#f97316", "#f59e0b", "#6366f1", "#8b5cf6", "#06b6d4" };
@for (int i = 0; i < Math.Min(_data.TopProducts.Count, 5); i++)
{
var p = _data.TopProducts[i];
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi" style="background:@(tpColors[i % tpColors.Length])">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>
</div>
<div class="flex-1"><p class="text-xs text-gray-500">@p.ProductName</p><p class="text-sm font-bold text-gray-900">@p.TotalMovement.ToString("N0")</p></div>
</div>
}
}
else
{
<p class="text-xs text-gray-400">No product data available</p>
}
</div>
<div class="mt-2 flex justify-between text-xs"><span>SKUs: <b>@_data.TotalSku.ToString("N0")</b></span><span>Value: <b>@_data.FormattedInvValue</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">Warehouse KPIs</h3>
<p class="text-xs text-gray-400 mb-3">Per warehouse summary</p>
<div class="space-y-2.5">
@if (_data.WarehouseKpis.Any())
{
@foreach (var wk in _data.WarehouseKpis)
{
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi" style="background:@wk.Color">
<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">@wk.WarehouseName</p><p class="text-xs text-gray-400">@wk.SkuCount SKUs</p></div>
<div class="text-right"><span class="chip chip-blue">@((decimal)wk.StockQty)</span></div>
</div>
}
}
else
{
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi gc1"><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">No warehouse data</p></div>
</div>
}
</div>
</div>
</div>
</div>
</div>
@code {
private DashboardWmsResponse _data = new();
private bool _isLoading = true;
private bool _chartsInitialized = false;
private bool _dataReady = false;
protected override async Task OnInitializedAsync()
{
_isLoading = true;
try
{
var res = await HomeService.GetWmsDashboardStatsAsync();
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()
{
// Chart 1: Stock Movement Trend (Inbound vs Outbound)
var movementLabels = _data.StockMovementTrend.Any()
? _data.StockMovementTrend.Select(x => x.Label ?? "").ToArray()
: new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun" };
var inboundData = _data.StockMovementTrend.Any()
? _data.StockMovementTrend.Select(x => x.InQty).ToArray()
: new double[6];
var outboundData = _data.StockMovementTrend.Any()
? _data.StockMovementTrend.Select(x => x.OutQty).ToArray()
: new double[6];
// Chart 2: Module Breakdown doughnut
var moduleLabels = _data.ModuleBreakdown.Any()
? _data.ModuleBreakdown.Select(x => x.Label ?? "").ToArray()
: new[] { "GR", "DO", "ADJ", "SCRP" };
var moduleData = _data.ModuleBreakdown.Any()
? _data.ModuleBreakdown.Select(x => (double)x.Count).Cast<object>().ToArray()
: new object[] { 10, 10, 10, 10 };
var moduleColors = _data.ModuleBreakdown.Any()
? _data.ModuleBreakdown.Select(x => x.Color ?? "#06b6d4").ToArray()
: new[] { "#06b6d4", "#10b981", "#6366f1", "#dc2626" };
// Chart 3: Yearly Movement bar
var yearlyLabels = _data.YearlyMovement.Any()
? _data.YearlyMovement.Select(x => x.Category ?? "").ToArray()
: new[] { "Inbound", "Outbound", "Net" };
var yearlyData = _data.YearlyMovement.Any()
? _data.YearlyMovement.Select(x => x.Qty).ToArray()
: new double[] { 0, 0, 0 };
var yearlyColors = _data.YearlyMovement.Any()
? _data.YearlyMovement.Select(x => x.Color ?? "#6366f1").ToArray()
: new[] { "#10b981", "#e11d48", "#6366f1" };
await JS.InvokeVoidAsync("initWmsDashboardCharts",
movementLabels, inboundData, outboundData,
moduleLabels, moduleData, moduleColors,
yearlyLabels, yearlyData, yearlyColors);
}
}
<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-cyan { background: #06b6d4; }
.chip-orange { background: #f97316; }
.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-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-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: #06b6d4; } .kpi-b2 { background: #10b981; } .kpi-b3 { background: #6366f1; } .kpi-b4 { background: #8b5cf6; }
.kpi-b5 { background: #f59e0b; } .kpi-b6 { background: #dc2626; } .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: #06b6d4; } .mm-c2 { background: #10b981; } .mm-c3 { background: #6366f1; } .mm-c4 { background: #8b5cf6; }
.mm-c5 { background: #f59e0b; } .mm-c6 { background: #dc2626; } .mm-c7 { background: #f97316; } .mm-c8 { background: #14b8a6; }
.mm-c9 { background: #2563eb; } .mm-c10 { background: #e11d48; } .mm-c11 { background: #db2777; } .mm-c12 { background: #7c3aed; }
.mm-c13 { background: #059669; } .mm-c14 { background: #d97706; } .mm-c15 { background: #0891b2; } .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: #06b6d4; } .gc2 { background: #10b981; } .gc3 { background: #6366f1; } .gc4 { background: #8b5cf6; }
.gc5 { background: #f59e0b; } .gc6 { background: #dc2626; } .gc7 { background: #f97316; } .gc8 { background: #14b8a6; }
.gc9 { background: #2563eb; } .gc10 { background: #e11d48; } .gc11 { background: #0891b2; }
</style>
@* Chart initialization script *@
<script suppress-error="BL9992">
window.initWmsDashboardCharts = function(movementLabels, inboundData, outboundData, moduleLabels, moduleData, moduleColors, yearlyLabels, yearlyData, yearlyColors) {
var grayText = '#94a3b8';
// Chart 1: Stock Movement Trend
var ctx1 = document.getElementById('plChart');
if (ctx1) {
new Chart(ctx1, {
type: 'bar',
data: {
labels: movementLabels,
datasets: [
{ label: 'Inbound', data: inboundData, backgroundColor: '#06b6d4', borderRadius: 4, barPercentage: 0.4 },
{ label: 'Outbound', data: outboundData, backgroundColor: '#10b981', borderRadius: 4, barPercentage: 0.4 }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { color: '#f1f5f9' }, ticks: { color: grayText, font: { size: 10 } } },
x: { grid: { display: false }, ticks: { color: grayText, font: { size: 9 } } }
}
}
});
}
// Chart 2: Module Breakdown Doughnut
var ctx2 = document.getElementById('expenseChart');
if (ctx2) {
new Chart(ctx2, {
type: 'doughnut',
data: {
labels: moduleLabels,
datasets: [{ data: moduleData, backgroundColor: moduleColors, borderWidth: 0 }]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '72%',
plugins: { legend: { display: false } }
}
});
}
// Chart 3: Yearly Movement
var ctx3 = document.getElementById('cashflowChart');
if (ctx3) {
new Chart(ctx3, {
type: 'bar',
data: {
labels: yearlyLabels,
datasets: [{ label: 'Movement', data: yearlyData, backgroundColor: yearlyColors, borderRadius: 4, barPercentage: 0.5 }]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { color: '#f1f5f9' }, ticks: { color: grayText, font: { size: 10 } } },
x: { grid: { display: false }, ticks: { color: grayText, font: { size: 9 } } }
}
}
});
}
};
</script>
+481
View File
@@ -0,0 +1,481 @@
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<WarehouseKpiDto> WarehouseKpis { get; set; } = new();
public List<WmsModuleBreakdownDto> ModuleBreakdown { get; set; } = new();
public List<WmsPipelineSummaryDto> PipelineSummary { get; set; } = new();
public List<WmsConversionMetricDto> ConversionMetrics { get; set; } = new();
public List<WmsRecentTransactionDto> RecentTransactions { get; set; } = new();
public List<WmsActivityFeedDto> ActivityFeeds { get; set; } = new();
public List<ChartDataPoint> RevenueTrend { get; set; } = new();
public List<WmsStockMovementPointDto> StockMovementTrend { get; set; } = new();
public List<WmsYearlyMovementDto> YearlyMovement { get; set; } = new();
public List<WmsTopProductDto> 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<DashboardWmsResponse>;
public class DashboardHandler : IRequestHandler<GetWmsDashboardQuery, DashboardWmsResponse>
{
private readonly AppDbContext _context;
public DashboardHandler(AppDbContext context) => _context = context;
public async Task<DashboardWmsResponse> 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<string> { "GR", "ADJ+", "TO-IN", "PRN" };
var outboundModules = new HashSet<string> { "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<string, string>
{
["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<WmsModuleBreakdownDto>
{
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<WmsPipelineSummaryDto>
{
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<WmsConversionMetricDto>
{
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<ChartDataPoint>();
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<WmsYearlyMovementDto>
{
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");
}
}
+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("/wms-stats", async (IMediator mediator) =>
{
var result = await mediator.Send(new GetWmsDashboardQuery());
return result.ToApiResponse("WMS Dashboard data retrieved successfully");
})
.WithName("GetWmsDashboardStats");
}
}
+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<DashboardWmsResponse>?> GetWmsDashboardStatsAsync()
{
var request = new RestRequest("api/dashboard/wms-stats", Method.Get);
request.AddHeader("Accept", "application/json");
return await ExecuteWithResponseAsync<DashboardWmsResponse>(_client, request);
}
}