initial commit

This commit is contained in:
2026-07-21 13:38:38 +07:00
commit 5047288f04
777 changed files with 57255 additions and 0 deletions
@@ -0,0 +1,671 @@
@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>OMS Dashboard</PageTitle>
@if (!_dataReady)
{
<div class="flex items-center justify-center" style="height:100vh;font-family:'Poppins',sans-serif;">
<div class="text-center">
<svg class="spinner" width="48" height="48" viewBox="0 0 24 24" fill="none" style="color:#8B5CF6;animation:spin 0.8s linear infinite;">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-dasharray="31.4 31.4" fill="none"/>
</svg>
<p class="mt-3 text-sm font-semibold" style="color:#8B5CF6;">Loading OMS Dashboard...</p>
</div>
</div>
}
else
{
<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;">OMS 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">OMS 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.FormattedMonthlySales</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.FormattedMonthlyPurchase</p>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-xs text-gray-500">Growth Rate</p>
<p class="text-lg font-bold text-gray-900">@_data.OrderGrowthRate%</p>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-xs text-gray-500">Total SO</p>
<p class="text-lg font-bold text-gray-900">@_data.FormattedTotalSalesOrders</p>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-xs text-gray-400">
<span>AR: <b class="text-gray-700">@_data.FormattedPendingPayments</b></span>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold" style="background:#eef2ff;color:#4f46e5;">FY @DateTime.Now.Year</span>
</div>
</div>
@* 8 Small KPI Cards (row 1, cols 6-12) *@
<div class="lg:col-span-7 grid grid-cols-2 lg:grid-cols-4 gap-3" style="animation: fadeInUp .5s ease forwards; animation-delay: .2s;">
<div class="kpi-card-sm kpi-b1">
<div class="flex items-start justify-between">
<div><p class="kpi-label">MTD Sales</p><p class="kpi-value">@_data.FormattedMonthlySales</p></div>
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></div>
</div>
</div>
<div class="kpi-card-sm kpi-b2">
<div class="flex items-start justify-between">
<div><p class="kpi-label">MTD Purchase</p><p class="kpi-value">@_data.FormattedMonthlyPurchase</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">Growth Rate</p><p class="kpi-value">@_data.OrderGrowthRate%</p></div>
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z"/></svg></div>
</div>
</div>
<div class="kpi-card-sm kpi-b4">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Total SO</p><p class="kpi-value">@_data.FormattedTotalSalesOrders</p></div>
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/></svg></div>
</div>
</div>
</div>
<div class="lg:col-span-7 lg:col-start-6 grid grid-cols-2 lg:grid-cols-4 gap-3" style="animation: fadeInUp .5s ease forwards; animation-delay: .3s;">
<div class="kpi-card-sm kpi-b5">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Avg Order Value</p><p class="kpi-value">@_data.FormattedAvgOrderValue</p></div>
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg></div>
</div>
</div>
<div class="kpi-card-sm kpi-b6">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Fulfillment Rate</p><p class="kpi-value">@_data.SlaPercentage%</p></div>
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg></div>
</div>
</div>
<div class="kpi-card-sm kpi-b7">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Pending Docs</p><p class="kpi-value">@_data.ActiveDealsCount</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">Total Payable</p><p class="kpi-value">@_data.FormattedTotalPayable</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">OMS Metrics Matrix</h3><p class="text-xs text-gray-400 mt-0.5">Key indicators across all categories</p></div>
</div>
<div class="grid grid-cols-2 md:grid-cols-5 gap-2">
@foreach (var item in _data.DocGrid.Take(20))
{
var mmColors = new[] { "mm-c1","mm-c2","mm-c3","mm-c4","mm-c5","mm-c6","mm-c7","mm-c8","mm-c9","mm-c10","mm-c11","mm-c12","mm-c13","mm-c14","mm-c15","mm-c16","mm-c17","mm-c18","mm-c19","mm-c20" };
var idx = _data.DocGrid.IndexOf(item);
var colorClass = idx < mmColors.Length ? mmColors[idx] : "mm-c1";
<div class="mm-cell">
<div class="mm-icon @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.Label</p><p class="mm-sub">@item.Value</p></div>
</div>
}
</div>
</div>
@* ROW 2: Revenue Trend + Document Sources *@
<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 comparison</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">Document Sources</h3>
<p class="text-xs text-gray-400 mb-3">By category this period</p>
<div class="flex justify-center"><div class="chart-container" style="height:170px;width:170px;"><canvas id="expenseChart"></canvas></div></div>
<div class="grid grid-cols-2 gap-1 mt-3 text-xs">
@if (_data.OrderSources.Any())
{
@foreach (var src in _data.OrderSources)
{
<div class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full" style="background:@src.Color"></span> @src.Source <span class="font-semibold ml-auto">@src.Percentage%</span>
</div>
}
}
else
{
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-full" style="background:#06b6d4"></span> SO <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> PO <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> INV <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> BILL <span class="font-semibold ml-auto">0%</span></div>
}
</div>
</div>
</div>
@* ROW 3: Pipeline Summary + Doc Stages + Order Activity *@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .3s;">
<h3 class="font-bold text-gray-900 mb-1">Pipeline 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.FormattedValue ?? ps.Value.ToString("N0"))</p></div>
</div>
}
}
<div class="flex items-center justify-between text-xs pt-2 border-t border-gray-100">
<span>Growth Rate</span><span class="font-semibold text-gray-900">@_data.OrderGrowthRate%</span>
<span>Orders</span><span class="font-semibold text-gray-900">@_data.FormattedTotalSalesOrders</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">Doc Stages</h3>
<p class="text-xs text-gray-400 mb-3">Orders by status</p>
<div class="space-y-2.5">
@foreach (var ds in _data.DealStages)
{
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi" style="background:@ds.Color">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
</div>
<div class="flex-1"><p class="text-xs text-gray-500">@ds.Stage</p><p class="text-sm font-bold" style="color:@ds.Color">@ds.Count</p></div>
</div>
}
</div>
<div class="mt-2 flex justify-between text-xs"><span>SLA: <b>@_data.SlaPercentage%</b></span><span>Active: <b>@_data.ActiveDealsCount</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">Order Activity</h3>
<p class="text-xs text-gray-400 mb-3">Document volume</p>
<div class="chart-container" style="height:170px;"><canvas id="cashflowChart"></canvas></div>
<div class="grid grid-cols-3 gap-1 mt-3 text-xs text-center">
@foreach (var sa in _data.OrderActivities.Take(3))
{
<div><span class="font-bold" style="color:@(sa.Category == "Sales Orders" ? "#10b981" : "#e11d48")">+@sa.Count</span><p class="text-gray-400">@sa.Category</p></div>
}
</div>
</div>
</div>
@* ROW 4: Recent Orders + OMS 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 Orders</h3><p class="text-xs text-gray-400 mt-0.5">Latest transactions</p></div>
</div>
<div class="grid-wrap">
<table class="grid-table">
<thead><tr><th>Date</th><th>Order#</th><th>Description</th><th>Value</th><th>Status</th></tr></thead>
<tbody>
@if (_data.RecentOrders.Any())
{
@foreach (var order in _data.RecentOrders)
{
<tr>
<td class="text-xs">@(order.Date?.ToString("MMM dd") ?? "-")</td>
<td class="font-mono text-xs">@order.OrderNumber</td>
<td class="text-xs">@order.Description</td>
<td class="font-mono text-xs">@order.Value.ToString("N0")</td>
<td><span class="chip @(order.Status == "Posted" ? "chip-green" : order.Status == "Cancelled" ? "chip-red" : "chip-amber")">@order.Status</span></td>
</tr>
}
}
else
{
<tr><td colspan="5" class="text-xs text-center text-gray-400 py-4">No recent orders available</td></tr>
}
</tbody>
</table>
</div>
<div class="mt-3 flex justify-between text-xs">
<span>Pending Docs: <b class="text-amber-600">@_data.ActiveDealsCount</b></span>
<span>Total AR: <b>@_data.FormattedPipeline</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">OMS 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="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0" style="background:@af.AvatarBg">@af.AvatarText</div>
<div class="min-w-0 flex-1"><p class="text-sm text-gray-700"><b>@af.Title</b></p><p class="text-xs text-gray-400 mt-0.5">@af.Detail</p><p class="text-xs text-gray-400">@af.TimeAgo</p></div>
<span class="chip @af.ChipColor self-start">@af.ChipLabel</span>
</div>
}
}
else
{
<div class="flex gap-3 p-2"><p class="text-xs text-gray-400">No recent activity</p></div>
}
</div>
</div>
</div>
@* ROW 5: Order Metrics + Top Customers + Quick Stats *@
<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">Order 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.OrderMetrics)
{
<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>Target SLA: <b class="text-green-600">@_data.TargetGrowth%</b></span>
<span class="text-indigo-600">@(_data.SlaPercentage >= 90 ? "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 Customers</h3>
<p class="text-xs text-gray-400 mb-3">By order volume</p>
<div class="space-y-2.5">
@if (_data.SalesLeaderboard.Any())
{
var perfColors = new[] { "#f97316", "#f59e0b", "#6366f1", "#8b5cf6" };
@for (int i = 0; i < Math.Min(_data.SalesLeaderboard.Count, 4); i++)
{
var p = _data.SalesLeaderboard[i];
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi" style="background:@(perfColors[i % perfColors.Length])">
<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">@p.Name</p><p class="text-sm font-bold text-gray-900">@p.Achievement.ToString("N0")</p></div>
<span class="text-xs font-semibold text-green-600">@p.WinRate.ToString("F0")%</span>
</div>
}
}
else
{
<p class="text-xs text-gray-400">No performance data available</p>
}
</div>
<div class="mt-2 flex justify-between text-xs"><span>Top Orders: <b>@_data.FormattedTotalSalesOrders</b></span><span>Avg Growth: <b>@_data.OrderGrowthRate%</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">Quick Stats</h3>
<p class="text-xs text-gray-400 mb-3">System overview</p>
<div class="space-y-2.5">
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi gc4"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg></div>
<div class="flex-1"><p class="text-xs text-gray-500">Pending Docs</p><p class="text-xs text-gray-400">Drafts SO+PO</p></div>
<div class="text-right"><span class="chip chip-blue">@_data.ActiveDealsCount</span></div>
</div>
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi gc8"><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">Total Customers</p><p class="text-xs text-gray-400">Active</p></div>
<div class="text-right"><span class="chip chip-purple">@_data.FormattedTotalCustomers</span></div>
</div>
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi gc11"><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">Retention Rate</p><p class="text-xs text-gray-400">Customer loyalty</p></div>
<div class="text-right"><span class="chip chip-orange">@_data.CustomerRetention%</span></div>
</div>
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi gc10"><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-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></div>
<div class="flex-1"><p class="text-xs text-gray-500">Receivable</p><p class="text-xs text-gray-400">Total AR</p></div>
<div class="text-right"><span class="chip chip-red">@_data.FormattedTotalReceivable</span></div>
</div>
</div>
</div>
</div>
@* ROW 6: Vendor Overview + Pending Invoices *@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .5s;">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">Vendor Overview</h3><p class="text-xs text-gray-400 mt-0.5">As of @DateTime.Now.ToString("MMM dd, yyyy") · Active vendors</p></div>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<div class="grid-wrap">
<table class="grid-table" style="background:transparent;">
<thead><tr><th>Vendor</th><th>Group</th><th>Amount</th><th>Stage</th></tr></thead>
<tbody>
@if (_data.RecentVendors.Any())
{
@foreach (var vend in _data.RecentVendors)
{
<tr>
<td class="text-xs">@vend.LeadTitle</td>
<td class="text-xs">@vend.Company</td>
<td class="font-mono text-xs text-green-600">@vend.Amount.ToString("N0")</td>
<td class="text-xs">@vend.Stage</td>
</tr>
}
}
else
{
<tr><td colspan="4" class="text-xs text-center text-gray-400 py-4">No vendors available</td></tr>
}
</tbody>
</table>
</div>
</div>
<div class="mt-3 pt-3 border-t border-gray-100 flex justify-between text-xs font-semibold">
<span>Total Vendors: <span class="text-green-600">@_data.TotalVendors.ToString("N0")</span></span>
<span>Products: <span class="text-indigo-600">@_data.TotalProducts.ToString("N0")</span></span>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5" style="animation: fadeInUp .5s ease forwards; animation-delay: .6s;">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">Pending Invoices</h3><p class="text-xs text-gray-400 mt-0.5">Awaiting confirmation</p></div>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<div class="grid-wrap">
<table class="grid-table" style="background:transparent;">
<thead><tr><th>Invoice</th><th>Customer</th><th>Amount</th><th>Status</th></tr></thead>
<tbody>
@if (_data.OverdueInvoices.Any())
{
@foreach (var inv in _data.OverdueInvoices)
{
<tr>
<td class="font-mono text-xs">@inv.Number</td>
<td class="text-xs">@inv.Customer</td>
<td class="font-mono text-xs">@inv.Amount.ToString("N0")</td>
<td>
<span class="chip @(inv.Status.Contains("Full") ? "chip-green" : inv.Status.Contains("Partial") ? "chip-amber" : "chip-red")">
@inv.Status
</span>
</td>
</tr>
}
}
else
{
<tr><td colspan="4" class="text-xs text-center text-gray-400 py-4">No pending invoices</td></tr>
}
</tbody>
</table>
</div>
</div>
<div class="mt-3 pt-3 border-t border-gray-100 flex justify-between text-xs">
<span>Total AR: <b class="text-gray-900">@_data.FormattedPendingPayments</b></span>
<span>Count: <b class="text-gray-900">@_data.OverdueInvoices.Count</b></span>
</div>
</div>
</div>
</div>
</div>
}
@code {
private DashboardOmsResponse _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.GetOmsDashboardStatsAsync();
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 ml = _data.RevenueTrend.Any()
? _data.RevenueTrend.Select(x => x.Label ?? "").ToArray()
: new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun" };
var ms = _data.RevenueTrend.Any()
? _data.RevenueTrend.Select(x => x.SalesAmount).ToArray()
: new double[ml.Length];
var mp = _data.RevenueTrend.Any()
? _data.RevenueTrend.Select(x => x.PurchaseAmount).ToArray()
: new double[ml.Length];
var sl = _data.OrderSources.Any()
? _data.OrderSources.Select(x => x.Source ?? "").ToArray()
: new[] { "SO", "PO", "INV", "BILL" };
var sd = _data.OrderSources.Any()
? _data.OrderSources.Select(x => x.Percentage).Cast<object>().ToArray()
: new object[] { 10, 10, 10, 10 };
var sc = _data.OrderSources.Any()
? _data.OrderSources.Select(x => x.Color ?? "#06b6d4").ToArray()
: new[] { "#06b6d4", "#10b981", "#6366f1", "#dc2626" };
var al = _data.OrderActivities.Any()
? _data.OrderActivities.Take(4).Select(x => x.Category ?? "").ToArray()
: new[] { "SO", "PO", "INV" };
var ad = _data.OrderActivities.Any()
? _data.OrderActivities.Take(4).Select(x => (double)x.Count).ToArray()
: new double[] { 0, 0, 0 };
var ac = _data.OrderActivities.Any()
? _data.OrderActivities.Take(4).Select(x => x.Color ?? "#6366f1").ToArray()
: new[] { "#10b981", "#e11d48", "#6366f1" };
await JS.InvokeVoidAsync("initOmsDashboardCharts",
ml, ms, mp,
sl, sd, sc,
al, ad, ac);
}
}
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
@@keyframes fadeInUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.chart-container { position: relative; width: 100%; }
.progress-bar { height: 8px; border-radius: 999px; background: #e2e8f0; overflow: hidden; }
.progress-bar .fill { height: 100%; border-radius: 999px; transition: width .8s ease; }
.grid-table th { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: #64748b; padding: 10px 12px; text-align: left; border-bottom: 1px solid #e2e8f0; }
.grid-table td { padding: 10px 12px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9; }
.grid-table tr:hover td { background: #f8fafc; }
.grid-table { width: 100%; border-collapse: collapse; }
.grid-wrap { max-height: 340px; overflow-y: auto; scrollbar-width: thin; }
.grid-wrap::-webkit-scrollbar { width: 4px; }
.grid-wrap::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
.chip { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 999px; font-size: 10px; font-weight: 600; color: #fff; }
.chip-blue { background: #2563eb; }
.chip-green { background: #10b981; }
.chip-amber { background: #f59e0b; }
.chip-red { background: #e11d48; }
.chip-indigo { background: #6366f1; }
.chip-purple { background: #8b5cf6; }
.chip-pink { background: #ec4899; }
.chip-cyan { background: #06b6d4; }
.chip-orange { background: #f97316; }
.chip-teal { background: #14b8a6; }
.activity-feed { max-height: 320px; overflow-y: auto; scrollbar-width: thin; }
.activity-feed::-webkit-scrollbar { width: 4px; }
.activity-feed::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
.kpi-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: 600; }
.kpi-card-sm { border-radius: 14px; padding: 18px; color: #fff; position: relative; overflow: hidden; min-height: 110px; display: flex; flex-direction: column; justify-content: space-between; }
.kpi-card-sm::after { content: ''; position: absolute; top: -30%; right: -20%; width: 80px; height: 80px; border-radius: 50%; background: rgba(255,255,255,.07); pointer-events: none; }
.kpi-card-sm .kpi-label { font-size: 9.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; opacity: .75; }
.kpi-card-sm .kpi-value { font-size: 20px; font-weight: 800; line-height: 1.1; margin-top: 2px; }
.kpi-card-sm .kpi-delta { font-size: 10px; font-weight: 600; }
.kpi-card-sm .kpi-icon { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,.15); flex-shrink: 0; }
.kpi-card-sm .kpi-icon svg { width: 16px; height: 16px; stroke: #fff; stroke-width: 2; }
.kpi-b1 { background: #2563eb; } .kpi-b2 { background: #10b981; } .kpi-b3 { background: #ec4899; } .kpi-b4 { background: #8b5cf6; }
.kpi-b5 { background: #f59e0b; } .kpi-b6 { background: #06b6d4; } .kpi-b7 { background: #f97316; } .kpi-b8 { background: #14b8a6; }
.mm-cell { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 10px; transition: background .2s; }
.mm-cell:hover { background: #f8fafc; }
.mm-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.mm-icon svg { width: 18px; height: 18px; stroke: #fff; stroke-width: 2; }
.mm-title { font-size: 12px; font-weight: 600; color: #334155; line-height: 1.2; }
.mm-sub { font-size: 10px; color: #94a3b8; margin-top: 1px; }
.mm-c1 { background: #2563eb; } .mm-c2 { background: #10b981; } .mm-c3 { background: #ec4899; } .mm-c4 { background: #8b5cf6; }
.mm-c5 { background: #f59e0b; } .mm-c6 { background: #06b6d4; } .mm-c7 { background: #f97316; } .mm-c8 { background: #14b8a6; }
.mm-c9 { background: #6366f1; } .mm-c10 { background: #e11d48; } .mm-c11 { background: #0891b2; } .mm-c12 { background: #7c3aed; }
.mm-c13 { background: #059669; } .mm-c14 { background: #d97706; } .mm-c15 { background: #db2777; } .mm-c16 { background: #0284c7; }
.mm-c17 { background: #65a30d; } .mm-c18 { background: #9333ea; } .mm-c19 { background: #ea580c; } .mm-c20 { background: #0d9488; }
.gi { width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.gi svg { width: 16px; height: 16px; stroke: #fff; stroke-width: 2; }
.gc1 { background: #2563eb; } .gc2 { background: #10b981; } .gc3 { background: #ec4899; } .gc4 { background: #8b5cf6; }
.gc5 { background: #f59e0b; } .gc6 { background: #06b6d4; } .gc7 { background: #f97316; } .gc8 { background: #14b8a6; }
.gc9 { background: #6366f1; } .gc10 { background: #e11d48; } .gc11 { background: #0891b2; }
</style>
@* Chart initialization script *@
<script suppress-error="BL9992">
window.initOmsDashboardCharts = function(ml, ms, mp, sl, sd, sc, al, ad, ac) {
var grayText = '#94a3b8';
// Chart 1: Sales vs Purchase Trend
var ctx1 = document.getElementById('plChart');
if (ctx1) {
new Chart(ctx1, {
type: 'bar',
data: {
labels: ml,
datasets: [
{ label: 'Sales', data: ms, backgroundColor: '#2563eb', borderRadius: 4, barPercentage: 0.4 },
{ label: 'Purchase', data: mp, 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 } } }
}
}
});
}
// Chart 2: Document Sources Doughnut
var ctx2 = document.getElementById('expenseChart');
if (ctx2) {
new Chart(ctx2, {
type: 'doughnut',
data: {
labels: sl,
datasets: [{ data: sd, backgroundColor: sc, borderWidth: 0 }]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '72%',
plugins: { legend: { display: false } }
}
});
}
// Chart 3: Order Activity
var ctx3 = document.getElementById('cashflowChart');
if (ctx3) {
new Chart(ctx3, {
type: 'bar',
data: {
labels: al,
datasets: [{ label: 'Count', data: ad, backgroundColor: ac, 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>
+357
View File
@@ -0,0 +1,357 @@
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 DealStageDto { public string? Stage { get; set; } public int Count { get; set; } public string? Color { get; set; } }
public class DocSourceDto { public string? Source { get; set; } public int Count { get; set; } public double Percentage { get; set; } public string? Color { get; set; } }
public class DocMetricDto { 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 OrderActivityDto { public string? Category { get; set; } public int Count { get; set; } public string? Color { get; set; } }
public class RecentOrderDto { public string? OrderNumber { get; set; } public string? Description { get; set; } public decimal Value { get; set; } public string? Stage { get; set; } public string? Status { get; set; } public DateTime? Date { get; set; } }
public class ActivityFeedDto { public string? Title { get; set; } public string? Detail { get; set; } public string? TimeAgo { get; set; } public string? ChipLabel { get; set; } public string? ChipColor { get; set; } public string? AvatarBg { get; set; } public string? AvatarText { get; set; } }
public class PipelineSummaryDto { public string? Label { get; set; } public decimal Value { get; set; } public string? IconColorClass { get; set; } public string? FormattedValue { get; set; } }
public class SalesPerformanceDto { public string? Name { get; set; } public decimal Achievement { get; set; } public int Deals { get; set; } public double WinRate { get; set; } }
public class PendingInvoiceDto { public string? Number { get; set; } public string? Customer { get; set; } public decimal Amount { get; set; } public string? Status { get; set; } }
public class RecentLeadDto { public string? LeadTitle { get; set; } public string? Company { get; set; } public decimal Amount { get; set; } public string? Stage { get; set; } }
public class DashboardOmsResponse
{
public int TotalSalesOrders { get; set; }
public int TotalPurchaseOrders { get; set; }
public decimal MonthlySales { get; set; }
public decimal MonthlyPurchase { get; set; }
public int TotalCustomers { get; set; }
public int TotalVendors { get; set; }
public decimal PendingReceivable { get; set; }
public decimal TotalPayable { get; set; }
public decimal TotalReceivable { get; set; }
public int ActiveDealsCount { get; set; }
public decimal AvgOrderValue { get; set; }
public double OrderGrowthRate { get; set; }
public int PendingSODraft { get; set; }
public int PendingPODraft { get; set; }
public int TotalProducts { get; set; }
public int TotalInvoices { get; set; }
public int TotalBills { get; set; }
public double SlaPercentage { get; set; }
public double CustomerRetention { get; set; }
public double TargetGrowth { get; set; }
public List<DealStageDto> DealStages { get; set; } = new();
public List<DocSourceDto> OrderSources { get; set; } = new();
public List<DocMetricDto> OrderMetrics { get; set; } = new();
public List<OrderActivityDto> OrderActivities { get; set; } = new();
public List<RecentOrderDto> RecentOrders { get; set; } = new();
public List<ActivityFeedDto> ActivityFeeds { get; set; } = new();
public List<PipelineSummaryDto> PipelineSummary { get; set; } = new();
public List<SalesPerformanceDto> SalesLeaderboard { get; set; } = new();
public List<PendingInvoiceDto> OverdueInvoices { get; set; } = new();
public List<RecentLeadDto> RecentVendors { get; set; } = new();
public List<ScmMetricDto> DocGrid { get; set; } = new();
public List<ChartDataPoint> RevenueTrend { get; set; } = new();
public string FormattedMonthlySales => FormatNumber(MonthlySales);
public string FormattedMonthlyPurchase => FormatNumber(MonthlyPurchase);
public string FormattedPipeline => FormatNumber(PendingReceivable);
public string FormattedPendingPayments => FormatNumber(PendingReceivable);
public string FormattedTotalPayable => FormatNumber(TotalPayable);
public string FormattedTotalReceivable => FormatNumber(TotalReceivable);
public string FormattedAvgOrderValue => FormatNumber(AvgOrderValue);
public string FormattedTotalSalesOrders => TotalSalesOrders.ToString("N0");
public string FormattedTotalPurchaseOrders => TotalPurchaseOrders.ToString("N0");
public string FormattedTotalCustomers => TotalCustomers.ToString("N0");
private string FormatNumber(decimal value)
{
if (value >= 1000000)
return (value / 1000000m).ToString("F1") + "M";
if (value >= 1000)
return (value / 1000m).ToString("F1") + "K";
return value.ToString("N0");
}
}
public record GetOmsDashboardQuery() : IRequest<DashboardOmsResponse>;
public class DashboardHandler : IRequestHandler<GetOmsDashboardQuery, DashboardOmsResponse>
{
private readonly AppDbContext _context;
public DashboardHandler(AppDbContext context) => _context = context;
public async Task<DashboardOmsResponse> Handle(GetOmsDashboardQuery request, CancellationToken ct)
{
var today = DateTime.Today;
var monthStart = new DateTime(today.Year, today.Month, 1);
// Monthly chart data
var monthlyLabels = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
var monthlyData = new List<ChartDataPoint>();
var soRaw = await _context.SalesOrder
.Where(x => x.OrderDate != null)
.Select(x => new { x.OrderDate, x.AfterTaxAmount })
.ToListAsync(ct);
var poRaw = await _context.PurchaseOrder
.Where(x => x.OrderDate != null)
.Select(x => new { x.OrderDate, x.AfterTaxAmount })
.ToListAsync(ct);
var allDates = soRaw.Select(d => d.OrderDate!.Value)
.Concat(poRaw.Select(d => d.OrderDate!.Value))
.Select(d => new { d.Year, d.Month })
.Distinct()
.OrderByDescending(x => x.Year).ThenByDescending(x => x.Month)
.Take(6).Reverse()
.ToList();
foreach (var ym in allDates)
{
var ms = new DateTime(ym.Year, ym.Month, 1);
var me = ms.AddMonths(1);
var salesAmount = soRaw.Where(x => x.OrderDate >= ms && x.OrderDate < me).Sum(x => (double)(x.AfterTaxAmount ?? 0));
var purchaseAmount = poRaw.Where(x => x.OrderDate >= ms && x.OrderDate < me).Sum(x => (double)(x.AfterTaxAmount ?? 0));
monthlyData.Add(new ChartDataPoint
{
Label = monthlyLabels[ym.Month - 1],
SalesAmount = Math.Round(salesAmount / 1000.0, 1),
PurchaseAmount = Math.Round(purchaseAmount / 1000.0, 1)
});
}
// Sequential queries (DbContext is not thread-safe)
var totalSO = await _context.SalesOrder.CountAsync(ct);
var totalPO = await _context.PurchaseOrder.CountAsync(ct);
var totalCust = await _context.Customer.CountAsync(ct);
var totalVend = await _context.Vendor.CountAsync(ct);
var totalInv = await _context.Invoice.CountAsync(ct);
var totalBill = await _context.Bill.CountAsync(ct);
var totalPR = await _context.PaymentReceive.CountAsync(ct);
var totalPD = await _context.PaymentDisburse.CountAsync(ct);
var totalProd = await _context.Set<Product>().CountAsync(ct);
var pendingSO = await _context.SalesOrder.CountAsync(x => x.OrderStatus == SalesOrderStatus.Draft, ct);
var pendingPO = await _context.PurchaseOrder.CountAsync(x => x.OrderStatus == PurchaseOrderStatus.Draft, ct);
var salesMTD = await _context.SalesOrder.Where(x => x.OrderDate >= monthStart).SumAsync(x => x.AfterTaxAmount ?? 0, ct);
var purchaseMTD = await _context.PurchaseOrder.Where(x => x.OrderDate >= monthStart).SumAsync(x => x.AfterTaxAmount ?? 0, ct);
var pendingAR = await _context.Invoice.Where(x => x.InvoiceStatus != InvoiceStatus.Confirmed).SumAsync(x => x.SalesOrder!.AfterTaxAmount ?? 0, ct);
var totalReceivable = await _context.Invoice.Where(x => x.InvoiceStatus == InvoiceStatus.Confirmed).SumAsync(x => x.SalesOrder!.AfterTaxAmount ?? 0, ct);
var totalPayable = await _context.Bill.Where(x => x.BillStatus == BillStatus.Confirmed).SumAsync(x => x.PurchaseOrder!.AfterTaxAmount ?? 0, ct);
// Doc stages (like deal stages)
var confirmedSO = await _context.SalesOrder.CountAsync(x => x.OrderStatus == SalesOrderStatus.Confirmed, ct);
var confirmedPO = await _context.PurchaseOrder.CountAsync(x => x.OrderStatus == PurchaseOrderStatus.Confirmed, ct);
var dealStages = new List<DealStageDto>
{
new() { Stage = "Draft SO", Count = pendingSO, Color = "#8b5cf6" },
new() { Stage = "Confirmed SO", Count = confirmedSO, Color = "#f59e0b" },
new() { Stage = "Draft PO", Count = pendingPO, Color = "#e11d48" },
new() { Stage = "Confirmed PO", Count = confirmedPO, Color = "#6366f1" }
};
// Doc sources (order types)
var totalDocs = totalSO + totalPO + totalInv + totalBill;
var orderSources = new List<DocSourceDto>
{
new() { Source = "Sales Orders", Count = totalSO, Percentage = totalDocs > 0 ? Math.Round((double)totalSO / totalDocs * 100, 1) : 0, Color = "#2563eb" },
new() { Source = "Purch Orders", Count = totalPO, Percentage = totalDocs > 0 ? Math.Round((double)totalPO / totalDocs * 100, 1) : 0, Color = "#f97316" },
new() { Source = "Invoices", Count = totalInv, Percentage = totalDocs > 0 ? Math.Round((double)totalInv / totalDocs * 100, 1) : 0, Color = "#ec4899" },
new() { Source = "Bills", Count = totalBill, Percentage = totalDocs > 0 ? Math.Round((double)totalBill / totalDocs * 100, 1) : 0, Color = "#8b5cf6" },
new() { Source = "Payments", Count = totalPR + totalPD, Percentage = totalDocs > 0 ? Math.Round((double)(totalPR + totalPD) / totalDocs * 100, 1) : 0, Color = "#06b6d4" }
};
// Order metrics (like conversion metrics)
var orderMetrics = new List<DocMetricDto>
{
new() { Label = "SO Draft→Confirm", Current = confirmedSO, Target = totalSO, PctValue = totalSO > 0 ? Math.Round((double)confirmedSO / totalSO * 100, 1) : 0, BarColor = "#2563eb" },
new() { Label = "SO→Invoice", Current = totalInv, Target = totalSO, PctValue = totalSO > 0 ? Math.Round((double)totalInv / totalSO * 100, 1) : 0, BarColor = "#f97316" },
new() { Label = "PO Draft→Confirm", Current = confirmedPO, Target = totalPO, PctValue = totalPO > 0 ? Math.Round((double)confirmedPO / totalPO * 100, 1) : 0, BarColor = "#ec4899" },
new() { Label = "Overall Fulfillment", Current = totalInv + totalBill, Target = totalSO + totalPO, PctValue = (totalSO + totalPO) > 0 ? Math.Round((double)(totalInv + totalBill) / (totalSO + totalPO) * 100, 1) : 0, BarColor = "#10b981" }
};
// Order activities
var orderActivities = new List<OrderActivityDto>
{
new() { Category = "Sales Orders", Count = totalSO, Color = "#10b981" },
new() { Category = "Purch Orders", Count = totalPO, Color = "#e11d48" },
new() { Category = "Invoices", Count = totalInv, Color = "#e11d48" },
new() { Category = "Total Docs", Count = totalDocs, Color = "#6366f1" }
};
// Recent orders
var recentOrders = await _context.SalesOrder
.AsNoTracking()
.OrderByDescending(x => x.OrderDate)
.Take(4)
.Select(x => new RecentOrderDto
{
OrderNumber = x.AutoNumber,
Description = x.Description,
Value = x.AfterTaxAmount ?? 0,
Stage = x.OrderStatus.ToString(),
Status = x.OrderStatus == SalesOrderStatus.Confirmed ? "Posted" : x.OrderStatus == SalesOrderStatus.Cancelled ? "Cancelled" : "Pending",
Date = x.OrderDate
}).ToListAsync(ct);
// Pipeline summary
var pipelineSummaryList = new List<PipelineSummaryDto>
{
new() { Label = "MTD Sales", Value = salesMTD, FormattedValue = FormatDecimal(salesMTD), IconColorClass = "gc1" },
new() { Label = "MTD Purchase", Value = purchaseMTD, FormattedValue = FormatDecimal(purchaseMTD), IconColorClass = "gc10" },
new() { Label = "Avg Order Value", Value = totalSO > 0 ? salesMTD / totalSO : 0, IconColorClass = "gc2" }
};
// Activity feed from recent SO
var recentSOs = await _context.SalesOrder
.AsNoTracking()
.OrderByDescending(x => x.OrderDate).Take(4)
.Select(x => new { x.AutoNumber, x.OrderDate, x.Customer!.Name })
.ToListAsync(ct);
var activityFeed = recentSOs.Select((so, i) => new ActivityFeedDto
{
Title = $"Sales Order #{so.AutoNumber}",
Detail = $"Customer: {so.Name}",
TimeAgo = so.OrderDate.HasValue ? $"{(int)(DateTime.Now - so.OrderDate.Value).TotalDays}d ago" : "",
ChipLabel = "SO Created",
ChipColor = i % 2 == 0 ? "chip-green" : "chip-indigo",
AvatarBg = i switch { 0 => "#10b981", 1 => "#2563eb", 2 => "#6366f1", _ => "#f97316" },
AvatarText = "SO"
}).ToList();
// Customer performance (top customers by count)
var topCustRaw = await _context.SalesOrder
.AsNoTracking()
.Where(x => x.Customer != null)
.GroupBy(x => x.Customer!.Name)
.OrderByDescending(g => g.Count())
.Take(4)
.Select(g => new { Name = g.Key ?? "N/A", Cnt = g.Count(), Val = g.Sum(x => x.AfterTaxAmount ?? 0) })
.ToListAsync(ct);
var salesLeaderboard = topCustRaw.Select(c => new SalesPerformanceDto
{
Name = c.Name,
Achievement = c.Val,
Deals = c.Cnt,
WinRate = totalSO > 0 ? Math.Round((double)c.Cnt / totalSO * 100, 1) : 0
}).ToList();
// Overdue invoices
var overdueInvoices = await _context.Invoice
.AsNoTracking()
.Where(x => x.InvoiceStatus != InvoiceStatus.Confirmed && x.InvoiceStatus != InvoiceStatus.Cancelled)
.OrderByDescending(x => x.CreatedAt).Take(5)
.Select(x => new PendingInvoiceDto
{
Number = x.AutoNumber,
Customer = x.SalesOrder!.Customer!.Name,
Amount = x.SalesOrder!.AfterTaxAmount ?? 0,
Status = x.InvoiceStatus.ToString()
}).ToListAsync(ct);
// Recent vendors (like recent leads)
var recentVendors = await _context.Vendor
.AsNoTracking()
.OrderBy(x => x.Name)
.Take(5)
.Select(x => new RecentLeadDto
{
LeadTitle = x.Name,
Company = x.VendorGroup != null ? x.VendorGroup.Name : "General",
Amount = 0,
Stage = "Active"
}).ToListAsync(ct);
// Compute derived values
var avgOrderValue = totalSO > 0 ? salesMTD / totalSO : 0;
var orderGrowthRate = totalSO > 0 ? Math.Round((double)totalInv / totalSO * 100, 1) : 42;
var slaPercentage = totalSO > 0 ? Math.Round((double)confirmedSO / totalSO * 100, 1) : 95;
var customerRetention = totalCust > 0 ? Math.Round((double)(totalCust - pendingSO) / totalCust * 100, 1) : 92;
// Doc grid for metrics matrix
var docGrid = new List<ScmMetricDto>
{
new() { Label = "Sales Orders", Value = totalSO.ToString(), Color = "#2563eb" },
new() { Label = "Purch Orders", Value = totalPO.ToString(), Color = "#10b981" },
new() { Label = "Invoices", Value = totalInv.ToString(), Color = "#ec4899" },
new() { Label = "Bills", Value = totalBill.ToString(), Color = "#8b5cf6" },
new() { Label = "Pay Receive", Value = totalPR.ToString(), Color = "#f59e0b" },
new() { Label = "Pay Disburse", Value = totalPD.ToString(), Color = "#06b6d4" },
new() { Label = "Customers", Value = totalCust.ToString("N0"), Color = "#14b8a6" },
new() { Label = "Vendors", Value = totalVend.ToString("N0"), Color = "#f97316" },
new() { Label = "Products", Value = totalProd.ToString("N0"), Color = "#059669" },
new() { Label = "Draft SO", Value = pendingSO.ToString(), Color = "#e11d48" },
new() { Label = "Draft PO", Value = pendingPO.ToString(), Color = "#db2777" },
new() { Label = "Pending AR", Value = FormatDecimal(pendingAR), Color = "#d97706" },
new() { Label = "Pending AP", Value = FormatDecimal(totalPayable), Color = "#0284c7" },
new() { Label = "Avg SO Value", Value = FormatDecimal(totalSO > 0 ? salesMTD / totalSO : 0), Color = "#65a30d" },
new() { Label = "Avg PO Value", Value = FormatDecimal(totalPO > 0 ? purchaseMTD / totalPO : 0), Color = "#9333ea" },
new() { Label = "SLA Target", Value = "95%", Color = "#ea580c" },
new() { Label = "Growth Rate", Value = $"{orderGrowthRate:F1}%", Color = "#0d9488" },
new() { Label = "Retention", Value = $"{customerRetention:F1}%", Color = "#7c3aed" },
new() { Label = "AR Balance", Value = FormatDecimal(totalReceivable), Color = "#0891b2" },
new() { Label = "AP Balance", Value = FormatDecimal(totalPayable), Color = "#db2777" }
};
return new DashboardOmsResponse
{
TotalSalesOrders = totalSO,
TotalPurchaseOrders = totalPO,
MonthlySales = salesMTD,
MonthlyPurchase = purchaseMTD,
TotalCustomers = totalCust,
TotalVendors = totalVend,
PendingReceivable = pendingAR,
TotalPayable = totalPayable,
TotalReceivable = totalReceivable,
ActiveDealsCount = pendingSO + pendingPO,
AvgOrderValue = avgOrderValue,
OrderGrowthRate = orderGrowthRate,
PendingSODraft = pendingSO,
PendingPODraft = pendingPO,
TotalProducts = totalProd,
TotalInvoices = totalInv,
TotalBills = totalBill,
SlaPercentage = slaPercentage,
CustomerRetention = customerRetention,
TargetGrowth = 5.0,
DealStages = dealStages,
OrderSources = orderSources,
OrderMetrics = orderMetrics,
OrderActivities = orderActivities,
RecentOrders = recentOrders,
ActivityFeeds = activityFeed,
PipelineSummary = pipelineSummaryList,
SalesLeaderboard = salesLeaderboard,
OverdueInvoices = overdueInvoices,
RecentVendors = recentVendors,
DocGrid = docGrid,
RevenueTrend = monthlyData
};
}
private static string FormatDecimal(decimal value)
{
if (value >= 1000000)
return (value / 1000000m).ToString("F1") + "M";
if (value >= 1000)
return (value / 1000m).ToString("F1") + "K";
return value.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("/oms-stats", async (IMediator mediator) =>
{
var result = await mediator.Send(new GetOmsDashboardQuery());
return result.ToApiResponse("Dashboard data retrieved successfully");
})
.WithName("GetOmsDashboardStats");
}
}
+28
View File
@@ -0,0 +1,28 @@
using Indotalent.ConfigBackEnd.Interfaces;
using Indotalent.ConfigFrontEnd.Common;
using Indotalent.Features.Root.Home.Cqrs;
using Indotalent.Infrastructure.Authentication.Identity;
using Indotalent.Shared.Models;
using Microsoft.AspNetCore.Components;
using MudBlazor;
using RestSharp;
namespace Indotalent.Features.Root.Home;
public class HomeService : BaseService
{
private readonly RestClient _client;
public HomeService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider)
: base(clientFactory, nav, snackbar, currentUserService, tokenProvider)
{
_client = new RestClient(nav.BaseUri);
}
public async Task<ApiResponse<DashboardOmsResponse>?> GetOmsDashboardStatsAsync()
{
var request = new RestRequest("api/dashboard/oms-stats", Method.Get);
request.AddHeader("Accept", "application/json");
return await ExecuteWithResponseAsync<DashboardOmsResponse>(_client, request);
}
}