initial commit
This commit is contained in:
@@ -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>SCM Enterprise Intelligence</PageTitle>
|
||||
|
||||
<div style="font-family: 'Poppins', sans-serif;">
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="flex flex-col items-center justify-center" style="min-height: 60vh;">
|
||||
<div class="w-12 h-12 rounded-full" style="border: 4px solid #e5e7eb; border-top-color: #10B981; animation: spin 0.8s linear infinite;"></div>
|
||||
<p class="mt-4 text-sm text-gray-500 font-medium">Loading SCM Dashboard...</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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;">SCM 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">SCM 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">MTD Purchase</p>
|
||||
<p class="text-lg font-bold text-gray-900">@_data.FormattedPurchase</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" style="background:#f8fafc;">
|
||||
<p class="text-xs text-gray-500">Total Receivable</p>
|
||||
<p class="text-lg font-bold text-gray-900">@_data.FormattedReceivable</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" style="background:#f8fafc;">
|
||||
<p class="text-xs text-gray-500">Total Payable</p>
|
||||
<p class="text-lg font-bold text-gray-900">@_data.FormattedPayable</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>Chain Accuracy: <b class="text-gray-700">@_data.ChainAccuracyIndex%</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 & 2) *@
|
||||
<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">Pending PR</p><p class="kpi-value">@_data.PendingPR</p></div>
|
||||
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card-sm kpi-b2">
|
||||
<div class="flex items-start justify-between">
|
||||
<div><p class="kpi-label">Pending SQ</p><p class="kpi-value">@_data.PendingSQ</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">Pending GR</p><p class="kpi-value">@_data.PendingGR</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 class="kpi-card-sm kpi-b4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div><p class="kpi-label">Pending DO</p><p class="kpi-value">@_data.PendingDO</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>
|
||||
<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">Active Cust</p><p class="kpi-value">@_data.ActiveCustomer</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 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card-sm kpi-b6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div><p class="kpi-label">Active Vend</p><p class="kpi-value">@_data.ActiveVendor</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 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>
|
||||
</div>
|
||||
<div class="kpi-card-sm kpi-b7">
|
||||
<div class="flex items-start justify-between">
|
||||
<div><p class="kpi-label">Inbound Qty</p><p class="kpi-value">@_data.InboundQtyMTD.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="M19 14l-7 7m0 0l-7-7m7 7V3"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card-sm kpi-b8">
|
||||
<div class="flex items-start justify-between">
|
||||
<div><p class="kpi-label">Outbound Qty</p><p class="kpi-value">@_data.OutboundQtyMTD.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="M5 10l7-7m0 0l7 7m-7-7v18"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ROW 2: Doc 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">SCM Document Matrix</h3><p class="text-xs text-gray-400 mt-0.5">All module document totals</p></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
@foreach (var doc in _data.DocGrid)
|
||||
{
|
||||
var colorClass = "mm-c" + ((Array.IndexOf(_data.DocGrid.ToArray(), doc) % 20) + 1);
|
||||
<div class="mm-cell">
|
||||
<div class="mm-icon" style="background:@doc.Color;">
|
||||
<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">@doc.Label</p><p class="mm-sub">@doc.Value docs</p></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ROW 3: Revenue Trend + Product Categories Donut *@
|
||||
<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">Sales vs Purchase Trend</h3><p class="text-xs text-gray-400 mt-0.5">Monthly revenue vs procurement</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</span>
|
||||
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm" style="background:#f97316;"></span> Purchase</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">Product Categories</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">By product group</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.ProductCategories.Any())
|
||||
{
|
||||
var donutColors = new[] { "#2563eb", "#f97316", "#ec4899", "#8b5cf6", "#06b6d4", "#10b981", "#f59e0b", "#e11d48" };
|
||||
@foreach (var cat in _data.ProductCategories.Take(8))
|
||||
{
|
||||
var idx = _data.ProductCategories.IndexOf(cat);
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full" style="background:@(donutColors[idx % donutColors.Length])"></span> @cat.Label <span class="font-semibold ml-auto">@cat.Value</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-full" style="background:#2563eb"></span> General <span class="font-semibold ml-auto">0</span></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ROW 4: PO Status + GR Status + Inventory Flow *@
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
@* PO Status Breakdown *@
|
||||
<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">PO Status Breakdown</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">Total: @_data.TotalPO POs</p>
|
||||
<div class="space-y-2.5">
|
||||
@foreach (var s in _data.POStatusBreakdown)
|
||||
{
|
||||
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
|
||||
<div class="gi" style="background:@(s.Color ?? "#64748b")">
|
||||
<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">@s.Status</p><p class="text-sm font-bold text-gray-900">@s.Count POs</p></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* GR Status Breakdown *@
|
||||
<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">GR Status Breakdown</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">Total: @_data.TotalGR GRs</p>
|
||||
<div class="space-y-2.5">
|
||||
@foreach (var s in _data.GRStatusBreakdown)
|
||||
{
|
||||
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
|
||||
<div class="gi" style="background:@(s.Color ?? "#64748b")">
|
||||
<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">@s.Status</p><p class="text-sm font-bold text-gray-900">@s.Count GRs</p></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Inventory Flow *@
|
||||
<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">Inventory Flow</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">MTD 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">
|
||||
<div><span class="font-bold text-green-600">@_data.InboundQtyMTD.ToString("N0")</span><p class="text-gray-400">Inbound</p></div>
|
||||
<div><span class="font-bold text-red-600">@_data.OutboundQtyMTD.ToString("N0")</span><p class="text-gray-400">Outbound</p></div>
|
||||
<div><span class="font-bold text-indigo-600">@_data.ChainAccuracyIndex%</span><p class="text-gray-400">Accuracy</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ROW 5: Recent POs + Recent GRs *@
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
@* Recent POs *@
|
||||
<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 Purchase Orders</h3><p class="text-xs text-gray-400 mt-0.5">Latest PO transactions</p></div>
|
||||
</div>
|
||||
<div class="grid-wrap">
|
||||
<table class="grid-table">
|
||||
<thead><tr><th>Date</th><th>PO#</th><th>Vendor</th><th>Amount</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
@if (_data.RecentPOs.Any())
|
||||
{
|
||||
@foreach (var po in _data.RecentPOs)
|
||||
{
|
||||
<tr>
|
||||
<td class="text-xs">@(po.OrderDate?.ToString("MMM dd") ?? "-")</td>
|
||||
<td class="font-mono text-xs">@po.Number</td>
|
||||
<td class="text-xs">@po.VendorName</td>
|
||||
<td class="font-mono text-xs">@po.Amount.ToString("N0")</td>
|
||||
<td><span class="chip @(po.Status == "Confirmed" ? "chip-blue" : po.Status == "Draft" ? "chip-amber" : po.Status == "Closed" ? "chip-green" : "chip-indigo")">@po.Status</span></td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<tr><td colspan="5" class="text-xs text-center text-gray-400 py-4">No recent POs available</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-between text-xs">
|
||||
<span>Total POs: <b class="text-indigo-600">@_data.TotalPO</b></span>
|
||||
<span>MTD Purchase: <b>@_data.FormattedPurchase</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Recent GRs *@
|
||||
<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 Goods Receives</h3><p class="text-xs text-gray-400 mt-0.5">Latest receiving activity</p></div>
|
||||
</div>
|
||||
<div class="grid-wrap">
|
||||
<table class="grid-table">
|
||||
<thead><tr><th>Date</th><th>GR#</th><th>PO Ref</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
@if (_data.RecentGRs.Any())
|
||||
{
|
||||
@foreach (var gr in _data.RecentGRs)
|
||||
{
|
||||
<tr>
|
||||
<td class="text-xs">@(gr.ReceiveDate?.ToString("MMM dd") ?? "-")</td>
|
||||
<td class="font-mono text-xs">@gr.Number</td>
|
||||
<td class="font-mono text-xs">@gr.PONumber</td>
|
||||
<td><span class="chip @(gr.Status == "Confirmed" ? "chip-blue" : "chip-amber")">@gr.Status</span></td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<tr><td colspan="4" class="text-xs text-center text-gray-400 py-4">No recent GRs available</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-between text-xs">
|
||||
<span>Total GRs: <b class="text-indigo-600">@_data.TotalGR</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ROW 6: Vendor PO Totals + PR/DO Status + Flow Log *@
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
@* Vendor PO Totals *@
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 flex flex-col" style="animation: fadeInUp .5s ease forwards; animation-delay: .4s;">
|
||||
<div class="flex-shrink-0">
|
||||
<h3 class="font-bold text-gray-900 mb-1">Top Vendors (PO)</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">Total PO value per vendor</p>
|
||||
</div>
|
||||
<div class="space-y-2.5 flex-1 min-h-0 overflow-y-auto custom-scrollbar">
|
||||
@if (_data.VendorPOTotals.Any())
|
||||
{
|
||||
@foreach (var v in _data.VendorPOTotals.Take(8))
|
||||
{
|
||||
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
|
||||
<div class="gi gc9">
|
||||
<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">@v.Label</p></div>
|
||||
<span class="text-xs font-semibold text-indigo-700">@v.Value</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-xs text-gray-400">No vendor data available</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* PR & DO Status *@
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 flex flex-col" style="animation: fadeInUp .5s ease forwards; animation-delay: .5s;">
|
||||
<div class="flex-shrink-0">
|
||||
<h3 class="font-bold text-gray-900 mb-1">PR & DO Status</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">Requisition & delivery overview</p>
|
||||
</div>
|
||||
<div class="space-y-2.5 flex-1 min-h-0 overflow-y-auto custom-scrollbar">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide">Purchase Requisitions</p>
|
||||
@foreach (var s in _data.PRStatusBreakdown)
|
||||
{
|
||||
<div class="flex items-center gap-3 p-2 rounded-lg" style="background:#f8fafc;">
|
||||
<div class="flex-1"><span class="text-xs text-gray-500">@s.Status</span></div>
|
||||
<span class="text-xs font-semibold">@s.Count</span>
|
||||
</div>
|
||||
}
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mt-3">Delivery Orders</p>
|
||||
@foreach (var s in _data.DOStatusBreakdown)
|
||||
{
|
||||
<div class="flex items-center gap-3 p-2 rounded-lg" style="background:#f8fafc;">
|
||||
<div class="flex-1"><span class="text-xs text-gray-500">@s.Status</span></div>
|
||||
<span class="text-xs font-semibold">@s.Count</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-shrink-0 mt-2 flex justify-between text-xs">
|
||||
<span>PRs: <b>@_data.TotalPR</b></span>
|
||||
<span>DOs: <b>@_data.TotalDO</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Supply Chain Flow Log *@
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 flex flex-col" style="animation: fadeInUp .5s ease forwards; animation-delay: .6s;">
|
||||
<div class="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<div><h3 class="font-bold text-gray-900">Supply Chain Flow</h3><p class="text-xs text-gray-400 mt-0.5">Real-time inventory movements</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 flex-1 min-h-0">
|
||||
@if (_data.GlobalFlowLog.Any())
|
||||
{
|
||||
@foreach (var tx in _data.GlobalFlowLog.Take(8))
|
||||
{
|
||||
var initials = (tx.Module ?? "SCM").Length >= 2 ? (tx.Module ?? "SCM")[..2].ToUpper() : "SC";
|
||||
var avatarColors = new[] { "#2563eb", "#10b981", "#f59e0b", "#6366f1", "#ec4899", "#e11d48", "#8b5cf6", "#06b6d4" };
|
||||
var avatarColor = avatarColors[Math.Abs(tx.Module?.GetHashCode() ?? 0) % avatarColors.Length];
|
||||
<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:@avatarColor;">@initials</div>
|
||||
<div class="min-w-0 flex-1"><p class="text-sm text-gray-700"><b>@tx.Module</b></p><p class="text-xs text-gray-400 mt-0.5">#@tx.Number · WH: @tx.Partner · Qty: @tx.Qty.ToString("N0")</p><p class="text-xs text-gray-400">@tx.Date?.ToString("dd MMM HH:mm")</p></div>
|
||||
<span class="chip @(tx.Status == "Confirmed" ? "chip-green" : "chip-amber") self-start">@tx.Status</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="flex gap-3 p-2"><p class="text-xs text-gray-400">No recent flow</p></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private DashboardScmResponse _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.GetScmDashboardStatsAsync();
|
||||
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.MonthlySalesVsPurchase.Any()
|
||||
? _data.MonthlySalesVsPurchase.Select(x => x.Label).ToArray()
|
||||
: new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun" };
|
||||
var salesData = _data.MonthlySalesVsPurchase.Any()
|
||||
? _data.MonthlySalesVsPurchase.Select(x => x.SalesAmount).ToArray()
|
||||
: new double[] { 0, 0, 0, 0, 0, 0 };
|
||||
var purchaseData = _data.MonthlySalesVsPurchase.Any()
|
||||
? _data.MonthlySalesVsPurchase.Select(x => x.PurchaseAmount).ToArray()
|
||||
: new double[] { 0, 0, 0, 0, 0, 0 };
|
||||
|
||||
var catList = _data.ProductCategories.Take(8).ToList();
|
||||
var defaultColors = new[] { "#2563eb", "#f97316", "#ec4899", "#8b5cf6", "#06b6d4", "#10b981", "#f59e0b", "#e11d48" };
|
||||
var donutLabels = catList.Any()
|
||||
? catList.Select(x => x.Label).ToArray()
|
||||
: new[] { "General" };
|
||||
var donutData = catList.Any()
|
||||
? catList.Select(x => double.TryParse(x.Value, out var v) ? v : 0).Cast<object>().ToArray()
|
||||
: new object[] { 0 };
|
||||
var donutColors = catList.Any()
|
||||
? catList.Select((x, i) => defaultColors[i % defaultColors.Length]).ToArray()
|
||||
: new[] { "#2563eb" };
|
||||
|
||||
var flowLabels = new[] { "Inbound", "Outbound", "Accuracy" };
|
||||
var flowCounts = new[] { _data.InboundQtyMTD, _data.OutboundQtyMTD, _data.ChainAccuracyIndex * 10 };
|
||||
var flowColors = new[] { "#10b981", "#e11d48", "#6366f1" };
|
||||
|
||||
await JS.InvokeVoidAsync("initDashboardCharts",
|
||||
revenueLabels, salesData, purchaseData,
|
||||
donutLabels, donutData, donutColors,
|
||||
flowLabels, flowCounts, flowColors);
|
||||
}
|
||||
}
|
||||
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
@@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@@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%; }
|
||||
|
||||
.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 { 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: #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>
|
||||
|
||||
@* Chart initialization script *@
|
||||
<script suppress-error="BL9992">
|
||||
window.initDashboardCharts = function(revenueLabels, salesData, purchaseData, donutLabels, donutData, donutColors, flowLabels, flowCounts, flowColors) {
|
||||
var grayText = '#94a3b8';
|
||||
var ctx1 = document.getElementById('plChart');
|
||||
if (ctx1) {
|
||||
new Chart(ctx1, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: revenueLabels,
|
||||
datasets: [
|
||||
{ label: 'Sales', data: salesData, backgroundColor: '#2563eb', borderRadius: 4, barPercentage: 0.4 },
|
||||
{ label: 'Purchase', data: purchaseData, backgroundColor: '#f97316', 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 }, callback: function(v) { return '$' + v + 'K'; } } },
|
||||
x: { grid: { display: false }, ticks: { color: grayText, font: { size: 9 } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
var ctx2 = document.getElementById('expenseChart');
|
||||
if (ctx2) {
|
||||
new Chart(ctx2, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: donutLabels,
|
||||
datasets: [{ data: donutData, backgroundColor: donutColors, borderWidth: 0 }]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '72%',
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
}
|
||||
var ctx3 = document.getElementById('cashflowChart');
|
||||
if (ctx3) {
|
||||
new Chart(ctx3, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: flowLabels,
|
||||
datasets: [{ label: 'Flow', data: flowCounts, backgroundColor: flowColors, 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>
|
||||
@@ -0,0 +1,649 @@
|
||||
using Indotalent.Infrastructure.Database;
|
||||
using Indotalent.Data.Entities;
|
||||
using Indotalent.Data.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Indotalent.Features.Root.Home.Cqrs;
|
||||
|
||||
public class ChartDataPoint
|
||||
{
|
||||
public string? Label { get; set; }
|
||||
public double SalesAmount { get; set; }
|
||||
public double PurchaseAmount { get; set; }
|
||||
}
|
||||
|
||||
public class ScmMetricDto
|
||||
{
|
||||
public string? Label { get; set; }
|
||||
public string? Value { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public double Percentage { get; set; }
|
||||
}
|
||||
|
||||
public class ScmActivityDto
|
||||
{
|
||||
public string? Module { get; set; }
|
||||
public string? Number { get; set; }
|
||||
public string? Partner { get; set; }
|
||||
public double Qty { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public DateTime? Date { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
public class RecentPurchaseOrderDto
|
||||
{
|
||||
public DateTime? OrderDate { get; set; }
|
||||
public string? Number { get; set; }
|
||||
public string? VendorName { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public int ItemCount { get; set; }
|
||||
}
|
||||
|
||||
public class RecentGoodsReceiveDto
|
||||
{
|
||||
public DateTime? ReceiveDate { get; set; }
|
||||
public string? Number { get; set; }
|
||||
public string? PONumber { get; set; }
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
public class StatusCountDto
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
public int Count { get; set; }
|
||||
public string? Color { get; set; }
|
||||
}
|
||||
|
||||
public class DashboardScmResponse
|
||||
{
|
||||
public int PendingPR { get; set; }
|
||||
public int PendingSQ { get; set; }
|
||||
public int PendingGR { get; set; }
|
||||
public int PendingDO { get; set; }
|
||||
public int ActiveCustomer { get; set; }
|
||||
public int ActiveVendor { get; set; }
|
||||
public double InboundQtyMTD { get; set; }
|
||||
public double OutboundQtyMTD { get; set; }
|
||||
public decimal SalesMTD { get; set; }
|
||||
public decimal PurchaseMTD { get; set; }
|
||||
public double ChainAccuracyIndex { get; set; }
|
||||
public decimal TotalReceivable { get; set; }
|
||||
public decimal TotalPayable { get; set; }
|
||||
public int TotalPR { get; set; }
|
||||
public int TotalPO { get; set; }
|
||||
public int TotalGR { get; set; }
|
||||
public int TotalDO { get; set; }
|
||||
|
||||
public List<ChartDataPoint> MonthlySalesVsPurchase { get; set; } = new();
|
||||
public List<ChartDataPoint> WeeklySalesVsPurchase { get; set; } = new();
|
||||
|
||||
public List<ScmMetricDto> DocGrid { get; set; } = new();
|
||||
public List<ScmActivityDto> GlobalFlowLog { get; set; } = new();
|
||||
public List<ScmMetricDto> TopProductsByQty { get; set; } = new();
|
||||
public List<ScmMetricDto> CustGroupStats { get; set; } = new();
|
||||
public List<ScmMetricDto> VendGroupStats { get; set; } = new();
|
||||
public List<ScmMetricDto> CustCategoryStats { get; set; } = new();
|
||||
public List<ScmMetricDto> VendCategoryStats { get; set; } = new();
|
||||
public List<ScmMetricDto> PayMethodStats { get; set; } = new();
|
||||
public List<ScmMetricDto> WhseInventoryLevel { get; set; } = new();
|
||||
public List<ScmMetricDto> CashFlowMTD { get; set; } = new();
|
||||
public List<ScmMetricDto> StockValuePerGroup { get; set; } = new();
|
||||
public List<ScmMetricDto> VendorPOTotals { get; set; } = new();
|
||||
public List<ScmMetricDto> ProductCategories { get; set; } = new();
|
||||
public List<StatusCountDto> POStatusBreakdown { get; set; } = new();
|
||||
public List<StatusCountDto> GRStatusBreakdown { get; set; } = new();
|
||||
public List<StatusCountDto> PRStatusBreakdown { get; set; } = new();
|
||||
public List<StatusCountDto> DOStatusBreakdown { get; set; } = new();
|
||||
public List<RecentPurchaseOrderDto> RecentPOs { get; set; } = new();
|
||||
public List<RecentGoodsReceiveDto> RecentGRs { get; set; } = new();
|
||||
|
||||
public string FormattedSales => FormatCurrency(SalesMTD);
|
||||
public string FormattedPurchase => FormatCurrency(PurchaseMTD);
|
||||
public string FormattedReceivable => FormatCurrency(TotalReceivable);
|
||||
public string FormattedPayable => FormatCurrency(TotalPayable);
|
||||
private static string FormatCurrency(decimal v) => v >= 1000000 ? "$" + (v / 1000000m).ToString("F2") + "M" : "$" + v.ToString("N0");
|
||||
}
|
||||
|
||||
public record GetScmDashboardQuery() : IRequest<DashboardScmResponse>;
|
||||
|
||||
public class DashboardHandler : IRequestHandler<GetScmDashboardQuery, DashboardScmResponse>
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
public DashboardHandler(AppDbContext context) => _context = context;
|
||||
|
||||
public async Task<DashboardScmResponse> Handle(GetScmDashboardQuery request, CancellationToken ct)
|
||||
{
|
||||
var start = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||||
|
||||
// --- Inventory Transactions (materialize with FK navigation via projection, no .Include) ---
|
||||
var txRaw = await _context.InventoryTransaction.AsNoTracking()
|
||||
.Where(x => x.MovementDate >= start)
|
||||
.Select(x => new
|
||||
{
|
||||
x.MovementDate,
|
||||
x.TransType,
|
||||
x.Status,
|
||||
x.Movement,
|
||||
x.QtySCSys,
|
||||
x.QtySCDelta,
|
||||
x.WarehouseId,
|
||||
x.ProductId,
|
||||
x.ModuleCode,
|
||||
x.ModuleName,
|
||||
x.ModuleNumber
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var warehouseIds = txRaw.Where(x => x.WarehouseId != null).Select(x => x.WarehouseId).Distinct().ToList();
|
||||
var productIds = txRaw.Where(x => x.ProductId != null).Select(x => x.ProductId).Distinct().ToList();
|
||||
|
||||
var warehouses = await _context.Warehouse.AsNoTracking()
|
||||
.Where(w => warehouseIds.Contains(w.Id))
|
||||
.Select(w => new { w.Id, w.Name })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var products = await _context.Product.AsNoTracking()
|
||||
.Where(p => productIds.Contains(p.Id))
|
||||
.Select(p => new { p.Id, p.Name, p.ProductGroupId, p.UnitPrice })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var productGroupIds = products.Where(p => p.ProductGroupId != null).Select(p => p.ProductGroupId).Distinct().ToList();
|
||||
var productGroups = await _context.ProductGroup.AsNoTracking()
|
||||
.Where(g => productGroupIds.Contains(g.Id))
|
||||
.Select(g => new { g.Id, g.Name })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var warehouseMap = warehouses.ToDictionary(w => w.Id, w => w.Name ?? "Main");
|
||||
var productMap = products.ToDictionary(p => p.Id, p => p.Name ?? "N/A");
|
||||
var productPriceMap = products.ToDictionary(p => p.Id, p => p.UnitPrice ?? 0);
|
||||
var groupMap = productGroups.ToDictionary(g => g.Id, g => g.Name ?? "General");
|
||||
|
||||
// Chain Accuracy Index
|
||||
var countData = txRaw.Where(x => x.ModuleCode == "COUNT").ToList();
|
||||
double accuracy = 100;
|
||||
if (countData.Count != 0)
|
||||
{
|
||||
var sys = countData.Sum(x => x.QtySCSys ?? 0);
|
||||
var delta = countData.Sum(x => Math.Abs(x.QtySCDelta ?? 0));
|
||||
accuracy = sys > 0 ? Math.Max(0, 100 - (delta / sys * 100)) : 100;
|
||||
}
|
||||
|
||||
// Inbound / Outbound MTD
|
||||
var inboundQty = txRaw.Where(x => x.TransType == InventoryTransType.In && x.Status == InventoryTransactionStatus.Confirmed)
|
||||
.Sum(x => x.Movement ?? 0);
|
||||
var outboundQty = txRaw.Where(x => x.TransType == InventoryTransType.Out && x.Status == InventoryTransactionStatus.Confirmed)
|
||||
.Sum(x => x.Movement ?? 0);
|
||||
|
||||
// Whse Inventory Level
|
||||
var whseLevel = txRaw
|
||||
.GroupBy(x => x.WarehouseId)
|
||||
.Select(g => new ScmMetricDto
|
||||
{
|
||||
Label = warehouseMap.GetValueOrDefault(g.Key ?? "", "Main"),
|
||||
Value = g.Sum(x => (x.Movement ?? 0) * (x.TransType == InventoryTransType.In ? 1 : -1)).ToString("N0")
|
||||
}).ToList();
|
||||
|
||||
// Stock Value Per Group
|
||||
var stockValGroup = txRaw
|
||||
.GroupBy(x => productIds.Contains(x.ProductId ?? "") ? groupMap.GetValueOrDefault(
|
||||
products.FirstOrDefault(p => p.Id == x.ProductId)?.ProductGroupId ?? "", "General") : "General")
|
||||
.Select(g => new ScmMetricDto
|
||||
{
|
||||
Label = g.Key,
|
||||
Value = g.Sum(x =>
|
||||
{
|
||||
var price = productPriceMap.GetValueOrDefault(x.ProductId ?? "", 0);
|
||||
return (decimal)((x.Movement ?? 0) * (x.TransType == InventoryTransType.In ? 1 : -1)) * price;
|
||||
}).ToString("N0")
|
||||
}).ToList();
|
||||
|
||||
// --- Customer Groups (no .Include - use CustomerGroupId FK) ---
|
||||
var custRaw = await _context.Customer.AsNoTracking()
|
||||
.Select(x => new { x.CustomerGroupId })
|
||||
.ToListAsync(ct);
|
||||
var custGroupIds = custRaw.Where(x => x.CustomerGroupId != null).Select(x => x.CustomerGroupId).Distinct().ToList();
|
||||
var custGroupsDb = await _context.CustomerGroup.AsNoTracking()
|
||||
.Where(g => custGroupIds.Contains(g.Id))
|
||||
.Select(g => new { g.Id, g.Name })
|
||||
.ToListAsync(ct);
|
||||
var custGroupMap = custGroupsDb.ToDictionary(g => g.Id, g => g.Name ?? "General");
|
||||
var custGroups = custRaw
|
||||
.GroupBy(x => custGroupMap.GetValueOrDefault(x.CustomerGroupId ?? "", "General"))
|
||||
.Select(g => new ScmMetricDto { Label = g.Key, Value = g.Count().ToString() })
|
||||
.ToList();
|
||||
|
||||
// --- Vendor Groups (no .Include - use VendorGroupId FK) ---
|
||||
var vendRaw = await _context.Vendor.AsNoTracking()
|
||||
.Select(x => new { x.VendorGroupId })
|
||||
.ToListAsync(ct);
|
||||
var vendGroupIds = vendRaw.Where(x => x.VendorGroupId != null).Select(x => x.VendorGroupId).Distinct().ToList();
|
||||
var vendGroupsDb = await _context.VendorGroup.AsNoTracking()
|
||||
.Where(g => vendGroupIds.Contains(g.Id))
|
||||
.Select(g => new { g.Id, g.Name })
|
||||
.ToListAsync(ct);
|
||||
var vendGroupMap = vendGroupsDb.ToDictionary(g => g.Id, g => g.Name ?? "General");
|
||||
var vendGroups = vendRaw
|
||||
.GroupBy(x => vendGroupMap.GetValueOrDefault(x.VendorGroupId ?? "", "General"))
|
||||
.Select(g => new ScmMetricDto { Label = g.Key, Value = g.Count().ToString() })
|
||||
.ToList();
|
||||
|
||||
// --- Customer Categories ---
|
||||
var custCatRaw = await _context.Customer.AsNoTracking()
|
||||
.Select(x => new { x.CustomerCategoryId })
|
||||
.ToListAsync(ct);
|
||||
var custCatIds = custCatRaw.Where(x => x.CustomerCategoryId != null).Select(x => x.CustomerCategoryId).Distinct().ToList();
|
||||
var custCatDb = await _context.CustomerCategory.AsNoTracking()
|
||||
.Where(c => custCatIds.Contains(c.Id))
|
||||
.Select(c => new { c.Id, c.Name })
|
||||
.ToListAsync(ct);
|
||||
var custCatMap = custCatDb.ToDictionary(c => c.Id, c => c.Name ?? "General");
|
||||
var custCats = custCatRaw
|
||||
.GroupBy(x => custCatMap.GetValueOrDefault(x.CustomerCategoryId ?? "", "General"))
|
||||
.Select(g => new ScmMetricDto { Label = g.Key, Value = g.Count().ToString() })
|
||||
.ToList();
|
||||
|
||||
// --- Vendor Categories ---
|
||||
var vendCatRaw = await _context.Vendor.AsNoTracking()
|
||||
.Select(x => new { x.VendorCategoryId })
|
||||
.ToListAsync(ct);
|
||||
var vendCatIds = vendCatRaw.Where(x => x.VendorCategoryId != null).Select(x => x.VendorCategoryId).Distinct().ToList();
|
||||
var vendCatDb = await _context.VendorCategory.AsNoTracking()
|
||||
.Where(c => vendCatIds.Contains(c.Id))
|
||||
.Select(c => new { c.Id, c.Name })
|
||||
.ToListAsync(ct);
|
||||
var vendCatMap = vendCatDb.ToDictionary(c => c.Id, c => c.Name ?? "General");
|
||||
var vendCats = vendCatRaw
|
||||
.GroupBy(x => vendCatMap.GetValueOrDefault(x.VendorCategoryId ?? "", "General"))
|
||||
.Select(g => new ScmMetricDto { Label = g.Key, Value = g.Count().ToString() })
|
||||
.ToList();
|
||||
|
||||
// --- Payment Methods (no .Include) ---
|
||||
var payRaw = await _context.PaymentReceive.AsNoTracking()
|
||||
.Select(x => new { x.PaymentMethodId })
|
||||
.ToListAsync(ct);
|
||||
var payMethodIds = payRaw.Where(x => x.PaymentMethodId != null).Select(x => x.PaymentMethodId).Distinct().ToList();
|
||||
var payMethodDb = await _context.PaymentMethod.AsNoTracking()
|
||||
.Where(m => payMethodIds.Contains(m.Id))
|
||||
.Select(m => new { m.Id, m.Name })
|
||||
.ToListAsync(ct);
|
||||
var payMethodMap = payMethodDb.ToDictionary(m => m.Id, m => m.Name ?? "Cash");
|
||||
var payMethods = payRaw
|
||||
.GroupBy(x => payMethodMap.GetValueOrDefault(x.PaymentMethodId ?? "", "Cash"))
|
||||
.Select(g => new ScmMetricDto { Label = g.Key, Value = g.Count().ToString() })
|
||||
.ToList();
|
||||
|
||||
// --- Top Products by Qty (no .Include - use ProductId FK) ---
|
||||
var soiRaw = await _context.SalesOrderItem.AsNoTracking()
|
||||
.Select(x => new { x.ProductId, x.Quantity })
|
||||
.ToListAsync(ct);
|
||||
var topProdIds = soiRaw.Where(x => x.ProductId != null).Select(x => x.ProductId).Distinct().ToList();
|
||||
var topProdDb = await _context.Product.AsNoTracking()
|
||||
.Where(p => topProdIds.Contains(p.Id))
|
||||
.Select(p => new { p.Id, p.Name })
|
||||
.ToListAsync(ct);
|
||||
var topProdMap = topProdDb.ToDictionary(p => p.Id, p => p.Name ?? "N/A");
|
||||
var topProds = soiRaw
|
||||
.Where(x => x.ProductId != null)
|
||||
.GroupBy(x => x.ProductId!)
|
||||
.Select(g => new ScmMetricDto { Label = topProdMap.GetValueOrDefault(g.Key, "N/A"), Value = g.Sum(x => x.Quantity ?? 0).ToString("N0") })
|
||||
.OrderByDescending(x => double.Parse(x.Value ?? "0"))
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
// --- Global Flow Log ---
|
||||
var globalLogs = await _context.InventoryTransaction
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(x => x.MovementDate)
|
||||
.Take(30)
|
||||
.Select(x => new ScmActivityDto
|
||||
{
|
||||
Module = x.ModuleName,
|
||||
Number = x.ModuleNumber,
|
||||
Partner = x.WarehouseId,
|
||||
Qty = x.Movement ?? 0,
|
||||
Status = x.Status.ToString(),
|
||||
Date = x.MovementDate,
|
||||
Description = x.ModuleCode
|
||||
}).ToListAsync(ct);
|
||||
var logWhIds = globalLogs.Where(x => x.Partner != null).Select(x => x.Partner).Distinct().ToList();
|
||||
var logWhDb = await _context.Warehouse.AsNoTracking()
|
||||
.Where(w => logWhIds.Contains(w.Id))
|
||||
.Select(w => new { w.Id, w.Name })
|
||||
.ToListAsync(ct);
|
||||
var logWhMap = logWhDb.ToDictionary(w => w.Id, w => w.Name ?? "N/A");
|
||||
foreach (var log in globalLogs)
|
||||
{
|
||||
if (log.Partner != null && logWhMap.TryGetValue(log.Partner, out var whName))
|
||||
log.Partner = whName;
|
||||
}
|
||||
|
||||
// --- Doc Grid ---
|
||||
var sqCount = await _context.SalesQuotation.CountAsync(ct);
|
||||
var soCount = await _context.SalesOrder.CountAsync(ct);
|
||||
var prCount = await _context.PurchaseRequisition.CountAsync(ct);
|
||||
var poCount = await _context.PurchaseOrder.CountAsync(ct);
|
||||
var grCount = await _context.GoodsReceive.CountAsync(ct);
|
||||
var doCount = await _context.DeliveryOrder.CountAsync(ct);
|
||||
var srCount = await _context.SalesReturn.CountAsync(ct);
|
||||
var prRetCount = await _context.PurchaseReturn.CountAsync(ct);
|
||||
var invCount = await _context.Invoice.CountAsync(ct);
|
||||
var billCount = await _context.Bill.CountAsync(ct);
|
||||
var cnCount = await _context.CreditNote.CountAsync(ct);
|
||||
var dnCount = await _context.DebitNote.CountAsync(ct);
|
||||
var payRecCount = await _context.PaymentReceive.CountAsync(ct);
|
||||
var payDisbCount = await _context.PaymentDisburse.CountAsync(ct);
|
||||
|
||||
var docGrid = new List<ScmMetricDto>
|
||||
{
|
||||
new() { Label = "Sales Quotation", Value = sqCount.ToString(), Color = "#3b82f6" },
|
||||
new() { Label = "Sales Order", Value = soCount.ToString(), Color = "#10b981" },
|
||||
new() { Label = "Purchase Req", Value = prCount.ToString(), Color = "#f59e0b" },
|
||||
new() { Label = "Purchase Order", Value = poCount.ToString(), Color = "#ef4444" },
|
||||
new() { Label = "Goods Receive", Value = grCount.ToString(), Color = "#64748b" },
|
||||
new() { Label = "Delivery Order", Value = doCount.ToString(), Color = "#06b6d4" },
|
||||
new() { Label = "Sales Return", Value = srCount.ToString(), Color = "#8b5cf6" },
|
||||
new() { Label = "Purch. Return", Value = prRetCount.ToString(), Color = "#f43f5e" },
|
||||
new() { Label = "Invoice", Value = invCount.ToString(), Color = "#14b8a6" },
|
||||
new() { Label = "Bill", Value = billCount.ToString(), Color = "#f97316" },
|
||||
new() { Label = "Credit Note", Value = cnCount.ToString(), Color = "#ec4899" },
|
||||
new() { Label = "Debit Note", Value = dnCount.ToString(), Color = "#0ea5e9" },
|
||||
new() { Label = "Pay Receive", Value = payRecCount.ToString(), Color = "#84cc16" },
|
||||
new() { Label = "Pay Disburse", Value = payDisbCount.ToString(), Color = "#d946ef" }
|
||||
};
|
||||
|
||||
// --- Monthly Sales vs Purchase ---
|
||||
var monthlyLabels = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
|
||||
var soMonths = await _context.SalesOrder
|
||||
.Where(x => x.OrderDate != null)
|
||||
.Select(x => x.OrderDate!.Value)
|
||||
.ToListAsync(ct);
|
||||
var poMonths = await _context.PurchaseOrder
|
||||
.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 monthlyData = new List<ChartDataPoint>();
|
||||
foreach (var ym in allDates)
|
||||
{
|
||||
var monthStartDt = new DateTime(ym.Year, ym.Month, 1);
|
||||
var monthEndDt = monthStartDt.AddMonths(1);
|
||||
var salesAmount = await _context.SalesOrder
|
||||
.Where(x => x.OrderDate >= monthStartDt && x.OrderDate < monthEndDt)
|
||||
.SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct);
|
||||
var purchaseAmount = await _context.PurchaseOrder
|
||||
.Where(x => x.OrderDate >= monthStartDt && x.OrderDate < monthEndDt)
|
||||
.SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct);
|
||||
monthlyData.Add(new ChartDataPoint
|
||||
{
|
||||
Label = monthlyLabels[ym.Month - 1],
|
||||
SalesAmount = Math.Round(salesAmount / 1000.0, 1),
|
||||
PurchaseAmount = Math.Round(purchaseAmount / 1000.0, 1)
|
||||
});
|
||||
}
|
||||
|
||||
// --- Weekly Sales vs Purchase ---
|
||||
var soWeeks = await _context.SalesOrder
|
||||
.Where(x => x.OrderDate != null)
|
||||
.Select(x => x.OrderDate!.Value)
|
||||
.ToListAsync(ct);
|
||||
var poWeeks = await _context.PurchaseOrder
|
||||
.Where(x => x.OrderDate != null)
|
||||
.Select(x => x.OrderDate!.Value)
|
||||
.ToListAsync(ct);
|
||||
var allWeekStarts = soWeeks.Concat(poWeeks)
|
||||
.Select(d => d.AddDays(-(int)d.DayOfWeek))
|
||||
.Distinct()
|
||||
.OrderByDescending(w => w)
|
||||
.Take(6)
|
||||
.Reverse()
|
||||
.ToList();
|
||||
var weeklyData = new List<ChartDataPoint>();
|
||||
foreach (var weekStart in allWeekStarts)
|
||||
{
|
||||
var weekEnd = weekStart.AddDays(7);
|
||||
var salesAmount = await _context.SalesOrder
|
||||
.Where(x => x.OrderDate >= weekStart && x.OrderDate < weekEnd)
|
||||
.SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct);
|
||||
var purchaseAmount = await _context.PurchaseOrder
|
||||
.Where(x => x.OrderDate >= weekStart && x.OrderDate < weekEnd)
|
||||
.SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct);
|
||||
weeklyData.Add(new ChartDataPoint
|
||||
{
|
||||
Label = $"Wk {weekStart:MM/dd}",
|
||||
SalesAmount = Math.Round(salesAmount / 1000.0, 1),
|
||||
PurchaseAmount = Math.Round(purchaseAmount / 1000.0, 1)
|
||||
});
|
||||
}
|
||||
|
||||
// --- Vendor PO Totals ---
|
||||
var poVendorRaw = await _context.PurchaseOrder.AsNoTracking()
|
||||
.Select(x => new { x.VendorId, x.AfterTaxAmount })
|
||||
.ToListAsync(ct);
|
||||
var poVendorIds = poVendorRaw.Where(x => x.VendorId != null).Select(x => x.VendorId).Distinct().ToList();
|
||||
var poVendorDb = await _context.Vendor.AsNoTracking()
|
||||
.Where(v => poVendorIds.Contains(v.Id))
|
||||
.Select(v => new { v.Id, v.Name })
|
||||
.ToListAsync(ct);
|
||||
var poVendorMap = poVendorDb.ToDictionary(v => v.Id, v => v.Name ?? "N/A");
|
||||
var vendorPOTotals = poVendorRaw
|
||||
.Where(x => x.VendorId != null)
|
||||
.GroupBy(x => x.VendorId!)
|
||||
.Select(g => new ScmMetricDto
|
||||
{
|
||||
Label = poVendorMap.GetValueOrDefault(g.Key, "N/A"),
|
||||
Value = g.Sum(x => x.AfterTaxAmount ?? 0).ToString("N0"),
|
||||
Color = "#6366f1"
|
||||
})
|
||||
.OrderByDescending(x => decimal.TryParse(x.Value, out var v) ? v : 0)
|
||||
.Take(15)
|
||||
.ToList();
|
||||
|
||||
// --- PO Status Breakdown ---
|
||||
var poStatusRaw = await _context.PurchaseOrder.AsNoTracking()
|
||||
.Select(x => x.OrderStatus)
|
||||
.ToListAsync(ct);
|
||||
var poStatusBreakdown = poStatusRaw
|
||||
.GroupBy(x => x)
|
||||
.Select(g => new StatusCountDto
|
||||
{
|
||||
Status = g.Key.ToString(),
|
||||
Count = g.Count(),
|
||||
Color = GetPOStatusColor(g.Key.ToString())
|
||||
}).ToList();
|
||||
|
||||
// --- GR Status Breakdown ---
|
||||
var grStatusRaw = await _context.GoodsReceive.AsNoTracking()
|
||||
.Select(x => x.Status)
|
||||
.ToListAsync(ct);
|
||||
var grStatusBreakdown = grStatusRaw
|
||||
.GroupBy(x => x)
|
||||
.Select(g => new StatusCountDto
|
||||
{
|
||||
Status = g.Key.ToString(),
|
||||
Count = g.Count(),
|
||||
Color = GetGRStatusColor(g.Key.ToString())
|
||||
}).ToList();
|
||||
|
||||
// --- PR Status Breakdown ---
|
||||
var prStatusRaw = await _context.PurchaseRequisition.AsNoTracking()
|
||||
.Select(x => x.RequisitionStatus)
|
||||
.ToListAsync(ct);
|
||||
var prStatusBreakdown = prStatusRaw
|
||||
.GroupBy(x => x)
|
||||
.Select(g => new StatusCountDto
|
||||
{
|
||||
Status = g.Key.ToString(),
|
||||
Count = g.Count(),
|
||||
Color = GetPOStatusColor(g.Key.ToString())
|
||||
}).ToList();
|
||||
|
||||
// --- DO Status Breakdown ---
|
||||
var doStatusRaw = await _context.DeliveryOrder.AsNoTracking()
|
||||
.Select(x => x.Status)
|
||||
.ToListAsync(ct);
|
||||
var doStatusBreakdown = doStatusRaw
|
||||
.GroupBy(x => x)
|
||||
.Select(g => new StatusCountDto
|
||||
{
|
||||
Status = g.Key.ToString(),
|
||||
Count = g.Count(),
|
||||
Color = GetGRStatusColor(g.Key.ToString())
|
||||
}).ToList();
|
||||
|
||||
// --- Recent POs ---
|
||||
var recentPOsRaw = await _context.PurchaseOrder.AsNoTracking()
|
||||
.OrderByDescending(x => x.OrderDate)
|
||||
.Take(10)
|
||||
.Select(x => new
|
||||
{
|
||||
x.OrderDate,
|
||||
x.AutoNumber,
|
||||
x.VendorId,
|
||||
x.AfterTaxAmount,
|
||||
x.OrderStatus,
|
||||
ItemCount = x.PurchaseOrderItemList != null ? x.PurchaseOrderItemList.Count : 0
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
var recentPOVendorIds = recentPOsRaw.Where(x => x.VendorId != null).Select(x => x.VendorId).Distinct().ToList();
|
||||
var recentPOVendorDb = await _context.Vendor.AsNoTracking()
|
||||
.Where(v => recentPOVendorIds.Contains(v.Id))
|
||||
.Select(v => new { v.Id, v.Name })
|
||||
.ToListAsync(ct);
|
||||
var recentPOVendorMap = recentPOVendorDb.ToDictionary(v => v.Id, v => v.Name ?? "N/A");
|
||||
var recentPOs = recentPOsRaw.Select(x => new RecentPurchaseOrderDto
|
||||
{
|
||||
OrderDate = x.OrderDate,
|
||||
Number = x.AutoNumber,
|
||||
VendorName = recentPOVendorMap.GetValueOrDefault(x.VendorId ?? "", "N/A"),
|
||||
Amount = x.AfterTaxAmount ?? 0,
|
||||
Status = x.OrderStatus.ToString(),
|
||||
ItemCount = x.ItemCount
|
||||
}).ToList();
|
||||
|
||||
// --- Recent GRs ---
|
||||
var recentGRsRaw = await _context.GoodsReceive.AsNoTracking()
|
||||
.OrderByDescending(x => x.ReceiveDate)
|
||||
.Take(10)
|
||||
.Select(x => new
|
||||
{
|
||||
x.ReceiveDate,
|
||||
x.AutoNumber,
|
||||
x.PurchaseOrderId,
|
||||
x.Status
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
var recentGRPOIds = recentGRsRaw.Where(x => x.PurchaseOrderId != null).Select(x => x.PurchaseOrderId).Distinct().ToList();
|
||||
var recentGRPODb = await _context.PurchaseOrder.AsNoTracking()
|
||||
.Where(po => recentGRPOIds.Contains(po.Id))
|
||||
.Select(po => new { po.Id, po.AutoNumber })
|
||||
.ToListAsync(ct);
|
||||
var recentGRPOMap = recentGRPODb.ToDictionary(po => po.Id, po => po.AutoNumber ?? "N/A");
|
||||
var recentGRs = recentGRsRaw.Select(x => new RecentGoodsReceiveDto
|
||||
{
|
||||
ReceiveDate = x.ReceiveDate,
|
||||
Number = x.AutoNumber,
|
||||
PONumber = recentGRPOMap.GetValueOrDefault(x.PurchaseOrderId ?? "", "N/A"),
|
||||
Status = x.Status.ToString()
|
||||
}).ToList();
|
||||
|
||||
// --- Product Categories for donut ---
|
||||
var prodCatRaw = await _context.Product.AsNoTracking()
|
||||
.Select(x => new { x.ProductGroupId })
|
||||
.ToListAsync(ct);
|
||||
var prodCatIds = prodCatRaw.Where(x => x.ProductGroupId != null).Select(x => x.ProductGroupId).Distinct().ToList();
|
||||
var prodCatDb = await _context.ProductGroup.AsNoTracking()
|
||||
.Where(g => prodCatIds.Contains(g.Id))
|
||||
.Select(g => new { g.Id, g.Name })
|
||||
.ToListAsync(ct);
|
||||
var prodCatMap = prodCatDb.ToDictionary(g => g.Id, g => g.Name ?? "General");
|
||||
var productCategories = prodCatRaw
|
||||
.GroupBy(x => prodCatMap.GetValueOrDefault(x.ProductGroupId ?? "", "General"))
|
||||
.Select(g => new ScmMetricDto { Label = g.Key, Value = g.Count().ToString() })
|
||||
.ToList();
|
||||
|
||||
// --- Invoice Receivable ---
|
||||
var totalReceivable = await _context.Invoice.AsNoTracking()
|
||||
.Where(x => x.InvoiceStatus == InvoiceStatus.Confirmed || x.InvoiceStatus == InvoiceStatus.PartialPaid)
|
||||
.SumAsync(x => x.SalesOrder != null ? (x.SalesOrder.AfterTaxAmount ?? 0) : 0, ct);
|
||||
|
||||
// --- Bill Payable ---
|
||||
var totalPayable = await _context.Bill.AsNoTracking()
|
||||
.Where(x => x.BillStatus == BillStatus.Confirmed || x.BillStatus == BillStatus.PartialPaid)
|
||||
.SumAsync(x => x.PurchaseOrder != null ? (x.PurchaseOrder.AfterTaxAmount ?? 0) : 0, ct);
|
||||
|
||||
var res = new DashboardScmResponse
|
||||
{
|
||||
PendingPR = await _context.PurchaseRequisition.CountAsync(x => x.RequisitionStatus == PurchaseRequisitionStatus.Draft, ct),
|
||||
PendingSQ = await _context.SalesQuotation.CountAsync(x => x.QuotationStatus == SalesQuotationStatus.Draft, ct),
|
||||
PendingGR = await _context.GoodsReceive.CountAsync(x => x.Status == GoodsReceiveStatus.Draft, ct),
|
||||
PendingDO = await _context.DeliveryOrder.CountAsync(x => x.Status == DeliveryOrderStatus.Draft, ct),
|
||||
ActiveCustomer = await _context.Customer.CountAsync(ct),
|
||||
ActiveVendor = await _context.Vendor.CountAsync(ct),
|
||||
InboundQtyMTD = inboundQty,
|
||||
OutboundQtyMTD = outboundQty,
|
||||
SalesMTD = await _context.SalesOrder.Where(x => x.OrderDate >= start).SumAsync(x => x.AfterTaxAmount ?? 0, ct),
|
||||
PurchaseMTD = await _context.PurchaseOrder.Where(x => x.OrderDate >= start).SumAsync(x => x.AfterTaxAmount ?? 0, ct),
|
||||
TotalReceivable = totalReceivable,
|
||||
TotalPayable = totalPayable,
|
||||
ChainAccuracyIndex = Math.Round(accuracy, 1),
|
||||
TotalPR = prCount,
|
||||
TotalPO = poCount,
|
||||
TotalGR = grCount,
|
||||
TotalDO = doCount,
|
||||
DocGrid = docGrid,
|
||||
GlobalFlowLog = globalLogs,
|
||||
TopProductsByQty = topProds,
|
||||
CustGroupStats = custGroups,
|
||||
VendGroupStats = vendGroups,
|
||||
CustCategoryStats = custCats,
|
||||
VendCategoryStats = vendCats,
|
||||
PayMethodStats = payMethods,
|
||||
WhseInventoryLevel = whseLevel,
|
||||
StockValuePerGroup = stockValGroup,
|
||||
CashFlowMTD = new List<ScmMetricDto>
|
||||
{
|
||||
new() { Label = "Inflow (Receive)", Value = (await _context.PaymentReceive.Where(x => x.PaymentDate >= start).SumAsync(x => x.PaymentAmount ?? 0, ct)).ToString("N0"), Color = "#10b981" },
|
||||
new() { Label = "Outflow (Disburse)", Value = (await _context.PaymentDisburse.Where(x => x.PaymentDate >= start).SumAsync(x => x.PaymentAmount ?? 0, ct)).ToString("N0"), Color = "#ef4444" }
|
||||
},
|
||||
MonthlySalesVsPurchase = monthlyData,
|
||||
WeeklySalesVsPurchase = weeklyData,
|
||||
VendorPOTotals = vendorPOTotals,
|
||||
ProductCategories = productCategories,
|
||||
POStatusBreakdown = poStatusBreakdown,
|
||||
GRStatusBreakdown = grStatusBreakdown,
|
||||
PRStatusBreakdown = prStatusBreakdown,
|
||||
DOStatusBreakdown = doStatusBreakdown,
|
||||
RecentPOs = recentPOs,
|
||||
RecentGRs = recentGRs
|
||||
};
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
private static string GetPOStatusColor(string status) => status switch
|
||||
{
|
||||
"Draft" => "#f59e0b",
|
||||
"Confirmed" => "#2563eb",
|
||||
"Closed" => "#10b981",
|
||||
"Cancelled" => "#e11d48",
|
||||
_ => "#64748b"
|
||||
};
|
||||
|
||||
private static string GetGRStatusColor(string status) => status switch
|
||||
{
|
||||
"Draft" => "#f59e0b",
|
||||
"Confirmed" => "#2563eb",
|
||||
"Closed" => "#10b981",
|
||||
"Cancelled" => "#e11d48",
|
||||
_ => "#64748b"
|
||||
};
|
||||
}
|
||||
@@ -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("/scm-stats", async (IMediator mediator) =>
|
||||
{
|
||||
var result = await mediator.Send(new GetScmDashboardQuery());
|
||||
return result.ToApiResponse("SCM Dashboard data retrieved successfully");
|
||||
})
|
||||
.WithName("GetScmDashboardStats");
|
||||
}
|
||||
}
|
||||
@@ -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<DashboardScmResponse>?> GetScmDashboardStatsAsync()
|
||||
{
|
||||
var request = new RestRequest("api/dashboard/scm-stats", Method.Get);
|
||||
request.AddHeader("Accept", "application/json");
|
||||
return await ExecuteWithResponseAsync<DashboardScmResponse>(_client, request);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user