initial commit

This commit is contained in:
2026-07-21 13:52:43 +07:00
commit f0e6f38940
881 changed files with 66309 additions and 0 deletions
@@ -0,0 +1,608 @@
@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>CMS Dashboard - Clinic Management System</PageTitle>
<div style="font-family: 'Poppins', sans-serif;">
<div class="p-4 md:p-6 space-y-6">
@* HEADER *@
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3" style="outline:none;">
<div>
<h1 class="text-2xl md:text-3xl font-bold text-gray-900" style="outline:none;">CMS 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">
<div>
<div class="flex items-center justify-between mb-4">
<div>
<p class="text-[10px] text-gray-500 font-semibold uppercase tracking-wide">CMS 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="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 class="grid grid-cols-2 gap-3 mt-2">
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-[10px] text-gray-500">Total Patients</p>
<p class="text-lg font-bold text-gray-900">@_data.FormattedTotalPatients</p>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-[10px] text-gray-500">Active Bookings</p>
<p class="text-lg font-bold text-gray-900">@_data.FormattedActiveAppointments</p>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-[10px] text-gray-500">Medical Records</p>
<p class="text-lg font-bold text-gray-900">@_data.FormattedTotalMedicalRecords</p>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-[10px] text-gray-500">Completion Rate</p>
<p class="text-lg font-bold text-gray-900">@_data.BookingCompletionRate%</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>MRR: <b class="text-gray-700">@_data.FormattedMRR</b></span>
<span class="kpi-badge" 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">
<div class="kpi-card-sm kpi-b1">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Total Patients</p><p class="kpi-value">@_data.FormattedTotalPatients</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">Active Bookings</p><p class="kpi-value">@_data.FormattedActiveAppointments</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">Completion Rate</p><p class="kpi-value">@_data.BookingCompletionRate%</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">Medical Records</p><p class="kpi-value">@_data.FormattedTotalMedicalRecords</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">
<div class="kpi-card-sm kpi-b5">
<div class="flex items-start justify-between">
<div><p class="kpi-label">Avg Booking</p><p class="kpi-value">@_data.FormattedAvgBooking</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">CVR</p><p class="kpi-value">@_data.OverallConversionRate%</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">Employees</p><p class="kpi-value">@_data.TotalEmployees</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">MRR</p><p class="kpi-value">@_data.FormattedMRR</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">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">CMS Metrics Matrix</h3><p class="text-xs text-gray-400 mt-0.5">Key indicators across all categories</p></div>
</div>
<div class="grid grid-cols-2 md:grid-cols-5 gap-2">
@{
var mmItems = new[] {
new { Title = "Total Patients", Sub = _data.FormattedTotalPatients, ColorClass = "mm-c1" },
new { Title = "Active Bookings", Sub = _data.FormattedActiveAppointments, ColorClass = "mm-c2" },
new { Title = "Completion Rate", Sub = $"{_data.BookingCompletionRate}%", ColorClass = "mm-c3" },
new { Title = "Medical Records", Sub = _data.FormattedTotalMedicalRecords, ColorClass = "mm-c4" },
new { Title = "Avg Booking", Sub = _data.FormattedAvgBooking, ColorClass = "mm-c5" },
new { Title = "CVR", Sub = $"{_data.OverallConversionRate}%", ColorClass = "mm-c6" },
new { Title = "Employees", Sub = _data.TotalEmployees.ToString(), ColorClass = "mm-c7" },
new { Title = "MRR", Sub = _data.FormattedMRR, ColorClass = "mm-c8" },
new { Title = "Products", Sub = _data.TotalProducts.ToString(), ColorClass = "mm-c9" },
new { Title = "Retention", Sub = $"{_data.PatientRetentionRate}%", ColorClass = "mm-c10" },
new { Title = "Conversion", Sub = $"{_data.OverallConversionRate}%", ColorClass = "mm-c11" },
new { Title = "SLA", Sub = $"{_data.SlaPercentage}%", ColorClass = "mm-c12" },
new { Title = "MTD Revenue", Sub = _data.FormattedSales, ColorClass = "mm-c13" },
new { Title = "MTD Purchase", Sub = _data.FormattedPurchase, ColorClass = "mm-c14" },
new { Title = "Receivable", Sub = _data.FormattedReceivable, ColorClass = "mm-c15" },
new { Title = "Payable", Sub = _data.FormattedPayable, ColorClass = "mm-c16" },
new { Title = "Draft SO", Sub = _data.PendingSO.ToString(), ColorClass = "mm-c17" },
new { Title = "Draft PO", Sub = _data.PendingPO.ToString(), ColorClass = "mm-c18" },
new { Title = "Draft Inv", Sub = _data.PendingInvoice.ToString(), ColorClass = "mm-c19" },
new { Title = "Enterprise", Sub = $"{_data.EnterprisePercentage}%", ColorClass = "mm-c20" }
};
}
@foreach (var item in mmItems)
{
<div class="mm-cell">
<div class="mm-icon @item.ColorClass">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<div><p class="mm-title">@item.Title</p><p class="mm-sub">@item.Sub</p></div>
</div>
}
</div>
</div>
@* ROW 2: Revenue Trend + Patient 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">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">Revenue Trend</h3><p class="text-xs text-gray-400 mt-0.5">Monthly revenue vs expense</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> Revenue</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm" style="background:#f97316;"></span> Expense</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">
<h3 class="font-bold text-gray-900 mb-1">Patient 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">
@foreach (var src in _data.LeadSources)
{
<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>
}
</div>
</div>
</div>
@* ROW 3: Pipeline Summary + Booking Stages + Patient 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">
<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">
@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>Completion</span><span class="font-semibold text-gray-900">@_data.BookingCompletionRate%</span>
<span>Patients</span><span class="font-semibold text-gray-900">@_data.FormattedTotalPatients</span>
</div>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5">
<h3 class="font-bold text-gray-900 mb-1">Booking Stages</h3>
<p class="text-xs text-gray-400 mb-3">Pipeline by stage</p>
<div class="space-y-2.5">
@foreach (var bs in _data.BookingStages)
{
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi" style="background:@bs.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">@bs.Stage</p><p class="text-sm font-bold" style="color:@bs.Color">@bs.Count</p></div>
</div>
}
</div>
<div class="mt-2 flex justify-between text-xs"><span>CVR: <b>@_data.OverallConversionRate%</b></span><span>Active: <b>@_data.ActiveAppointments</b></span></div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5">
<h3 class="font-bold text-gray-900 mb-1">Patient Activity</h3>
<p class="text-xs text-gray-400 mb-3">Monthly movement</p>
<div class="chart-container" style="height:170px;"><canvas id="cashflowChart"></canvas></div>
<div class="grid grid-cols-3 gap-1 mt-3 text-[10px] text-center">
@foreach (var sa in _data.SalesActivities.Take(3))
{
<div><span class="font-bold" style="color:@(sa.Category == "Calls" ? "#10b981" : "#e11d48")">+@sa.Count</span><p class="text-gray-400">@sa.Category</p></div>
}
</div>
</div>
</div>
@* ROW 4: Recent Bookings + CMS 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">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">Recent Bookings</h3><p class="text-xs text-gray-400 mt-0.5">Latest appointments</p></div>
</div>
<div class="grid-wrap">
<table class="grid-table">
<thead><tr><th>Date</th><th>Booking#</th><th>Patient</th><th>Resource</th><th>Status</th></tr></thead>
<tbody>
@if (_data.RecentBookings.Any())
{
@foreach (var bk in _data.RecentBookings)
{
<tr>
<td class="text-xs">@(bk.Date?.ToString("MMM dd") ?? "-")</td>
<td class="font-mono text-xs">@bk.Number</td>
<td class="text-xs">@bk.Patient</td>
<td class="text-xs">@bk.Resource</td>
<td><span class="chip @(bk.Status == "Completed" ? "chip-indigo" : bk.Status == "Confirmed" ? "chip-green" : "chip-amber")">@bk.Status</span></td>
</tr>
}
}
else
{
<tr><td colspan="5" class="text-xs text-center text-gray-400 py-4">No recent bookings available</td></tr>
}
</tbody>
</table>
</div>
<div class="mt-3 flex justify-between text-xs">
<span>Active: <b class="text-amber-600">@_data.ActiveAppointments</b></span>
<span>Total Bookings: <b>@_data.FormattedTotalBookings</b></span>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">CMS 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: Conversion Metrics + Patient Distribution + Support Metrics *@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-white rounded-xl border border-gray-100 p-5">
<h3 class="font-bold text-gray-900 mb-1">Conversion Metrics</h3>
<p class="text-xs text-gray-400 mb-3">@DateTime.Now.Year Performance</p>
<div class="space-y-2.5">
@foreach (var cm in _data.ConversionMetrics)
{
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<div class="flex items-center gap-3">
<div class="gi" style="background:@cm.BarColor">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-center">
<p class="text-xs text-gray-500 font-semibold">@cm.Label</p>
<span class="text-[11px] 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 CVR: <b class="text-green-600">@_data.TargetCvr%</b></span>
<span class="text-indigo-600">@(_data.OverallConversionRate >= 5 ? "On Track" : "Improving")</span>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5">
<h3 class="font-bold text-gray-900 mb-1">Patient Distribution</h3>
<p class="text-xs text-gray-400 mb-3">By patient group</p>
<div class="space-y-2.5">
@if (_data.PatientDistribution.Any())
{
var distColors = new[] { "#f97316", "#f59e0b", "#6366f1", "#8b5cf6", "#2563eb" };
@for (int i = 0; i < Math.Min(_data.PatientDistribution.Count, 5); i++)
{
var pd = _data.PatientDistribution[i];
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi" style="background:@(distColors[i % distColors.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">@pd.Label</p><p class="text-sm font-bold text-gray-900">@pd.Value</p></div>
<span class="text-xs font-semibold text-green-600">@pd.PctValue%</span>
</div>
}
}
else
{
<p class="text-xs text-gray-400">No distribution data available</p>
}
</div>
<div class="mt-2 flex justify-between text-xs"><span>Total Groups: <b>@_data.PatientDistribution.Count</b></span><span>Total: <b>@_data.FormattedTotalPatients</b></span></div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5">
<h3 class="font-bold text-gray-900 mb-1">Revenue Metrics</h3>
<p class="text-xs text-gray-400 mb-3">Financial KPIs</p>
<div class="space-y-2.5">
@foreach (var rm in _data.RevenueMetrics)
{
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi" style="background:@rm.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">@rm.Label</p></div>
<div class="text-right"><span class="chip chip-blue">@rm.FormattedValue</span></div>
</div>
}
</div>
</div>
</div>
@* ROW 6: Document Lifecycle + Recent Sales Orders *@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-white rounded-xl border border-gray-100 p-5">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">Document Lifecycle</h3><p class="text-xs text-gray-400 mt-0.5">System transaction volume</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>Document Type</th><th>Count</th></tr></thead>
<tbody>
@foreach (var doc in _data.DocGrid)
{
<tr>
<td class="text-xs font-semibold">@doc.Label</td>
<td class="font-mono text-xs" style="color:@doc.Color;font-weight:700;">@doc.Value</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>Draft SO: <span class="text-amber-600">@_data.PendingSO</span></span>
<span>Draft PO: <span class="text-amber-600">@_data.PendingPO</span></span>
<span>Draft Inv: <span class="text-amber-600">@_data.PendingInvoice</span></span>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">Recent Patient Activity</h3><p class="text-xs text-gray-400 mt-0.5">Latest sales orders</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>Ref#</th><th>Customer</th><th>Qty</th><th>Date</th><th>Status</th></tr></thead>
<tbody>
@if (_data.GlobalFlowLog.Any())
{
@foreach (var tx in _data.GlobalFlowLog.Take(6))
{
<tr>
<td class="font-mono text-xs">@tx.Number</td>
<td class="text-xs">@tx.Partner</td>
<td class="font-mono text-xs">@tx.Qty.ToString("N0")</td>
<td class="text-xs">@(tx.Date?.ToString("MMM dd") ?? "-")</td>
<td><span class="chip @(tx.Status == "Confirmed" ? "chip-green" : tx.Status == "Draft" ? "chip-amber" : "chip-indigo")">@tx.Status</span></td>
</tr>
}
}
else
{
<tr><td colspan="5" class="text-xs text-center text-gray-400 py-4">No transactions available</td></tr>
}
</tbody>
</table>
</div>
</div>
<div class="mt-3 pt-3 border-t border-gray-100 flex justify-between text-xs">
<span>Total Transactions: <b class="text-gray-900">@_data.GlobalFlowLog.Count</b></span>
</div>
</div>
</div>
</div>
</div>
@code {
private DashboardScmResponse _data = new();
private bool _isLoading = true;
private bool _chartsInitialized = false;
protected override async Task OnInitializedAsync()
{
var res = await HomeService.GetScmDashboardStatsAsync();
if (res?.IsSuccess == true && res.Value != null)
{
_data = res.Value;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!_chartsInitialized && _data.RevenueTrend.Count > 0)
{
_chartsInitialized = true;
await InitCharts();
}
}
private async Task InitCharts()
{
var revenueLabels = _data.RevenueTrend.Select(x => x.Label).ToArray();
var revenueData = _data.RevenueTrend.Select(x => x.SalesAmount).ToArray();
var expenseData = _data.RevenueTrend.Select(x => x.PurchaseAmount).ToArray();
var sourceLabels = _data.LeadSources.Select(x => x.Source).ToArray();
var sourceData = _data.LeadSources.Select(x => x.Percentage).Cast<object>().ToArray();
var sourceColors = _data.LeadSources.Select(x => x.Color).ToArray();
var activityLabels = _data.SalesActivities.Take(4).Select(x => x.Category).ToArray();
var activityCounts = _data.SalesActivities.Take(4).Select(x => (double)x.Count).ToArray();
var activityColors = _data.SalesActivities.Take(4).Select(x => x.Color).ToArray();
await JS.InvokeVoidAsync("initDashboardCharts",
revenueLabels, revenueData, expenseData,
sourceLabels, sourceData, sourceColors,
activityLabels, activityCounts, activityColors);
}
}
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
@@keyframes fadeInUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.chart-container { position: relative; width: 100%; }
.progress-bar { height: 8px; border-radius: 999px; background: #e2e8f0; overflow: hidden; }
.progress-bar .fill { height: 100%; border-radius: 999px; transition: width .8s ease; }
.grid-table th { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: #64748b; padding: 10px 12px; text-align: left; border-bottom: 1px solid #e2e8f0; }
.grid-table td { padding: 10px 12px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9; }
.grid-table tr:hover td { background: #f8fafc; }
.grid-table { width: 100%; border-collapse: collapse; }
.grid-wrap { max-height: 340px; overflow-y: auto; scrollbar-width: thin; }
.grid-wrap::-webkit-scrollbar { width: 4px; }
.grid-wrap::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
.chip { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 999px; font-size: 10px; font-weight: 600; color: #fff; }
.chip-blue { background: #2563eb; }
.chip-green { background: #10b981; }
.chip-amber { background: #f59e0b; }
.chip-red { background: #e11d48; }
.chip-indigo { background: #6366f1; }
.chip-purple { background: #8b5cf6; }
.chip-pink { background: #ec4899; }
.chip-cyan { background: #06b6d4; }
.chip-orange { background: #f97316; }
.chip-teal { background: #14b8a6; }
.activity-feed { max-height: 320px; overflow-y: auto; scrollbar-width: thin; }
.activity-feed::-webkit-scrollbar { width: 4px; }
.activity-feed::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
.kpi-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: 600; }
.kpi-card-sm { border-radius: 14px; padding: 18px; color: #fff; position: relative; overflow: hidden; min-height: 110px; display: flex; flex-direction: column; justify-content: space-between; }
.kpi-card-sm::after { content: ''; position: absolute; top: -30%; right: -20%; width: 80px; height: 80px; border-radius: 50%; background: rgba(255,255,255,.07); pointer-events: none; }
.kpi-card-sm .kpi-label { font-size: 9.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; opacity: .75; }
.kpi-card-sm .kpi-value { font-size: 20px; font-weight: 800; line-height: 1.1; margin-top: 2px; }
.kpi-card-sm .kpi-delta { font-size: 10px; font-weight: 600; }
.kpi-card-sm .kpi-icon { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,.15); flex-shrink: 0; }
.kpi-card-sm .kpi-icon svg { width: 16px; height: 16px; stroke: #fff; stroke-width: 2; }
.kpi-b1 { background: #2563eb; } .kpi-b2 { background: #10b981; } .kpi-b3 { background: #ec4899; } .kpi-b4 { background: #8b5cf6; }
.kpi-b5 { background: #f59e0b; } .kpi-b6 { background: #06b6d4; } .kpi-b7 { background: #f97316; } .kpi-b8 { background: #14b8a6; }
.mm-cell { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 10px; transition: background .2s; }
.mm-cell:hover { background: #f8fafc; }
.mm-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.mm-icon svg { width: 18px; height: 18px; stroke: #fff; stroke-width: 2; }
.mm-title { font-size: 12px; font-weight: 600; color: #334155; line-height: 1.2; }
.mm-sub { font-size: 10px; color: #94a3b8; margin-top: 1px; }
.mm-c1 { background: #2563eb; } .mm-c2 { background: #10b981; } .mm-c3 { background: #ec4899; } .mm-c4 { background: #8b5cf6; }
.mm-c5 { background: #f59e0b; } .mm-c6 { background: #06b6d4; } .mm-c7 { background: #f97316; } .mm-c8 { background: #14b8a6; }
.mm-c9 { background: #6366f1; } .mm-c10 { background: #e11d48; } .mm-c11 { background: #0891b2; } .mm-c12 { background: #7c3aed; }
.mm-c13 { background: #059669; } .mm-c14 { background: #d97706; } .mm-c15 { background: #db2777; } .mm-c16 { background: #0284c7; }
.mm-c17 { background: #65a30d; } .mm-c18 { background: #9333ea; } .mm-c19 { background: #ea580c; } .mm-c20 { background: #0d9488; }
.gi { width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.gi svg { width: 16px; height: 16px; stroke: #fff; stroke-width: 2; }
.gc1 { background: #2563eb; } .gc2 { background: #10b981; } .gc3 { background: #ec4899; } .gc4 { background: #8b5cf6; }
.gc5 { background: #f59e0b; } .gc6 { background: #06b6d4; } .gc7 { background: #f97316; } .gc8 { background: #14b8a6; }
.gc9 { background: #6366f1; } .gc10 { background: #e11d48; } .gc11 { background: #0891b2; }
</style>
@* Chart initialization script *@
<script suppress-error="BL9992">
window.initDashboardCharts = function(revenueLabels, revenueData, expenseData, sourceLabels, sourceData, sourceColors, activityLabels, activityCounts, activityColors) {
var grayText = '#94a3b8';
var ctx1 = document.getElementById('plChart');
if (ctx1) {
new Chart(ctx1, {
type: 'bar',
data: {
labels: revenueLabels,
datasets: [
{ label: 'Revenue', data: revenueData, backgroundColor: '#2563eb', borderRadius: 4, barPercentage: 0.4 },
{ label: 'Expense', data: expenseData, 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: sourceLabels,
datasets: [{ data: sourceData, backgroundColor: sourceColors, 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: activityLabels,
datasets: [{ label: 'Activity', data: activityCounts, backgroundColor: activityColors, 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>
+524
View File
@@ -0,0 +1,524 @@
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; }
}
// CMS-specific DTOs for dashboard3 reference layout
public class DealStageDto { public string? Stage { get; set; } public int Count { get; set; } public string? Color { get; set; } }
public class LeadSourceDto { public string? Source { get; set; } public int Count { get; set; } public double Percentage { get; set; } public string? Color { get; set; } }
public class ConversionMetricDto { 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 SalesActivityDto { public string? Category { get; set; } public int Count { get; set; } public string? Color { get; set; } }
public class RecentDealDto { public string? DealNumber { get; set; } public string? Description { get; set; } public decimal Value { get; set; } public string? Stage { get; set; } public string? Status { get; set; } public DateTimeOffset? Date { get; set; } }
public class ActivityFeedDto { public string? Title { get; set; } public string? Detail { get; set; } public string? TimeAgo { get; set; } public string? ChipLabel { get; set; } public string? ChipColor { get; set; } public string? AvatarBg { get; set; } public string? AvatarText { get; set; } }
public class PipelineSummaryDto { public string? Label { get; set; } public decimal Value { get; set; } public string? IconColorClass { get; set; } public string? FormattedValue { get; set; } }
public class PatientDistributionDto { public string? Label { get; set; } public string? Value { get; set; } public double PctValue { get; set; } public string? BarColor { get; set; } }
public class BookingStageDto { public string? Stage { get; set; } public int Count { get; set; } public string? Color { get; set; } }
public class RecentBookingDto { public string? Number { get; set; } public string? Patient { get; set; } public string? Resource { get; set; } public DateTime? Date { get; set; } public string? Status { get; set; } }
public class AppointmentActivityDto { public string? Type { get; set; } public string? PatientName { 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 RevenueMetricDto { public string? Label { get; set; } public decimal Value { get; set; } public string? FormattedValue { get; set; } public string? Color { get; set; } }
public class DashboardScmResponse
{
// Original properties (keep backward compatibility)
public int PendingSO { get; set; }
public int PendingPO { get; set; }
public int PendingInvoice { get; set; }
public int PendingBill { get; set; }
public int TotalCustomer { get; set; }
public int TotalVendor { get; set; }
public decimal SalesMTD { get; set; }
public decimal PurchaseMTD { get; set; }
public decimal TotalReceivable { get; set; }
public decimal TotalPayable { get; set; }
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> PayMethodStats { get; set; } = new();
public List<ScmMetricDto> CashFlowMTD { get; set; } = new();
public string FormattedSales => FormatCurrency(SalesMTD);
public string FormattedPurchase => FormatCurrency(PurchaseMTD);
public string FormattedReceivable => FormatCurrency(TotalReceivable);
public string FormattedPayable => FormatCurrency(TotalPayable);
// CMS-specific dashboard properties
public int TotalPatients { get; set; }
public int TotalBookings { get; set; }
public int TotalMedicalRecords { get; set; }
public int ActiveAppointments { get; set; }
public decimal MRR { get; set; }
public double BookingCompletionRate { get; set; }
public int TotalEmployees { get; set; }
public int TotalProducts { get; set; }
public decimal AvgBookingValue { get; set; }
public double OverallConversionRate { get; set; }
public double PatientRetentionRate { get; set; }
public double SlaPercentage { get; set; }
public double EnterprisePercentage { get; set; }
public double TargetCvr { get; set; }
public List<DealStageDto> DealStages { get; set; } = new();
public List<LeadSourceDto> LeadSources { get; set; } = new();
public List<ConversionMetricDto> ConversionMetrics { get; set; } = new();
public List<SalesActivityDto> SalesActivities { get; set; } = new();
public List<RecentDealDto> RecentDeals { get; set; } = new();
public List<ActivityFeedDto> ActivityFeeds { get; set; } = new();
public List<PipelineSummaryDto> PipelineSummary { get; set; } = new();
public List<ChartDataPoint> RevenueTrend { get; set; } = new();
public List<PatientDistributionDto> PatientDistribution { get; set; } = new();
public List<BookingStageDto> BookingStages { get; set; } = new();
public List<RecentBookingDto> RecentBookings { get; set; } = new();
public List<AppointmentActivityDto> AppointmentActivities { get; set; } = new();
public List<RevenueMetricDto> RevenueMetrics { get; set; } = new();
public string FormattedTotalPatients => TotalPatients.ToString("N0");
public string FormattedTotalBookings => TotalBookings.ToString("N0");
public string FormattedMRR => FormatCurrency(MRR);
public string FormattedAvgBooking => FormatCurrency(AvgBookingValue);
public string FormattedActiveAppointments => ActiveAppointments.ToString("N0");
public string FormattedTotalMedicalRecords => TotalMedicalRecords.ToString("N0");
public static string FormatCurrency(decimal v)
{
if (v >= 1000000) return (v / 1000000m).ToString("F1") + "M";
if (v >= 1000) return (v / 1000m).ToString("F1") + "K";
return 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);
// ===================================================================
// APPROACH: Take top 1000 raw records → materialize → process in memory
// ===================================================================
// ---- Patient raw data (top 1000) ----
var patientsRaw = await _context.Patient
.OrderByDescending(x => x.CreatedAt)
.Take(1000)
.Select(x => new { x.Name, GroupName = x.PatientGroup != null ? x.PatientGroup.Name : null, CategoryName = x.PatientCategory != null ? x.PatientCategory.Name : null })
.ToListAsync(ct);
var totalPatients = patientsRaw.Count;
// Patient distribution by group (processed in memory)
var patientGroupStats = patientsRaw
.GroupBy(x => string.IsNullOrEmpty(x.GroupName) ? "General" : x.GroupName)
.Select(g => new PatientDistributionDto
{
Label = g.Key,
Value = g.Count().ToString(),
PctValue = totalPatients > 0 ? Math.Round((double)g.Count() / totalPatients * 100, 1) : 0,
BarColor = "#2563eb"
}).OrderByDescending(x => x.PctValue).Take(5).ToList();
// Lead Sources / Patient Sources (grouped by category in memory)
var patientGroupCounts = patientsRaw
.GroupBy(x => string.IsNullOrEmpty(x.CategoryName) ? "General" : x.CategoryName)
.Select(g => new { Label = g.Key, Count = g.Count() })
.OrderByDescending(x => x.Count)
.Take(5)
.ToList();
var totalSourceCount = patientGroupCounts.Sum(x => x.Count);
var sourceColors = new[] { "#2563eb", "#f97316", "#ec4899", "#8b5cf6", "#06b6d4" };
var leadSources = patientGroupCounts
.Select((g, i) => new LeadSourceDto
{
Source = g.Label,
Count = g.Count,
Percentage = totalSourceCount > 0 ? Math.Round((double)g.Count / totalSourceCount * 100, 1) : 0,
Color = i < sourceColors.Length ? sourceColors[i] : "#94a3b8"
}).ToList();
// ---- Vendor raw data (top 1000) ----
var vendorsRaw = await _context.Vendor
.OrderByDescending(x => x.CreatedAt)
.Take(1000)
.Select(x => new { x.Name, GroupName = x.VendorGroup != null ? x.VendorGroup.Name : null })
.ToListAsync(ct);
var vendGroups = vendorsRaw
.GroupBy(x => string.IsNullOrEmpty(x.GroupName) ? "General" : x.GroupName)
.Select(g => new ScmMetricDto { Label = g.Key, Value = g.Count().ToString() })
.ToList();
// ---- Payment methods raw data (top 1000) ----
var paymentsRaw = await _context.PaymentReceive
.OrderByDescending(x => x.CreatedAt)
.Take(1000)
.Select(x => new { MethodName = x.PaymentMethod != null ? x.PaymentMethod.Name : null })
.ToListAsync(ct);
var payMethods = paymentsRaw
.GroupBy(x => string.IsNullOrEmpty(x.MethodName) ? "Cash" : x.MethodName)
.Select(g => new ScmMetricDto { Label = g.Key, Value = g.Count().ToString() })
.ToList();
// ---- Product raw data (top 1000 sales order items) ----
var soiRaw = await _context.SalesOrderItem
.OrderByDescending(x => x.CreatedAt)
.Take(1000)
.Select(x => new { ProductName = x.Product != null ? x.Product.Name : null, Qty = x.Quantity ?? 0 })
.ToListAsync(ct);
var topProds = soiRaw
.GroupBy(x => string.IsNullOrEmpty(x.ProductName) ? "N/A" : x.ProductName)
.OrderByDescending(g => g.Sum(x => (double)x.Qty))
.Take(10)
.Select(g => new ScmMetricDto { Label = g.Key, Value = g.Sum(x => (double)x.Qty).ToString("N0") })
.ToList();
// ---- Global flow log (sales orders, top 1000 → take 20) ----
var soRaw = await _context.SalesOrder
.OrderByDescending(x => x.OrderDate)
.Take(1000)
.Select(x => new
{
x.AutoNumber,
CustomerName = x.Customer != null ? x.Customer.Name : "N/A",
TotalQty = x.SalesOrderItemList.Sum(i => i.Quantity ?? 0),
Status = x.OrderStatus.ToString(),
x.OrderDate
})
.ToListAsync(ct);
var globalLogs = soRaw
.Take(20)
.Select(x => new ScmActivityDto
{
Module = "Sales Order",
Number = x.AutoNumber,
Partner = x.CustomerName,
Qty = x.TotalQty,
Status = x.Status,
Date = x.OrderDate
}).ToList();
// ---- Doc counts (simple count queries, fine as-is) ----
var docGrid = new List<ScmMetricDto> {
new() { Label = "Sales Order", Value = (await _context.SalesOrder.CountAsync(ct)).ToString(), Color = "#10b981" },
new() { Label = "Purchase Order", Value = (await _context.PurchaseOrder.CountAsync(ct)).ToString(), Color = "#ef4444" },
new() { Label = "Invoice", Value = (await _context.Invoice.CountAsync(ct)).ToString(), Color = "#14b8a6" },
new() { Label = "Bill", Value = (await _context.Bill.CountAsync(ct)).ToString(), Color = "#f97316" },
new() { Label = "Pay Receive", Value = (await _context.PaymentReceive.CountAsync(ct)).ToString(), Color = "#84cc16" },
new() { Label = "Pay Disburse", Value = (await _context.PaymentDisburse.CountAsync(ct)).ToString(), Color = "#d946ef" }
};
// ---- CMS totals ----
var totalBookings = await _context.Booking.CountAsync(ct);
var totalMedicalRecords = await _context.MedicalRecord.CountAsync(ct);
var activeBookings = await _context.Booking.CountAsync(x => x.Status == BookingStatus.Confirmed, ct);
var totalEmployees = await _context.Employee.CountAsync(ct);
var totalProducts = await _context.Product.CountAsync(ct);
// ---- Booking raw data (top 1000) ----
var bookingsRaw = await _context.Booking
.OrderByDescending(x => x.CreatedAt)
.Take(1000)
.Select(x => new
{
x.AutoNumber,
x.Subject,
ResourceName = x.BookingResource != null ? x.BookingResource.Name : "N/A",
x.StartTime,
Status = x.Status
})
.ToListAsync(ct);
var completedBookings = bookingsRaw.Count(x => x.Status == BookingStatus.Done);
var bookingCompletionRate = totalBookings > 0 ? Math.Round((double)completedBookings / totalBookings * 100, 1) : 0;
// Booking stages (processed from raw data in memory)
var bookingStages = bookingsRaw
.GroupBy(x => x.Status)
.Select(g => new BookingStageDto
{
Stage = g.Key.ToString(),
Count = g.Count(),
Color = g.Key switch
{
BookingStatus.Draft => "#8b5cf6",
BookingStatus.Confirmed => "#f59e0b",
BookingStatus.OnProgress => "#e11d48",
BookingStatus.Done => "#6366f1",
_ => "#94a3b8"
}
}).ToList();
// Recent bookings (top 4 from materialized)
var recentBookings = bookingsRaw
.Take(4)
.Select(x => new RecentBookingDto
{
Number = x.AutoNumber,
Patient = x.Subject ?? "N/A",
Resource = x.ResourceName,
Date = x.StartTime,
Status = x.Status.ToString()
}).ToList();
// Recent deals (from bookings)
var recentDeals = recentBookings.Select(b => new RecentDealDto
{
DealNumber = b.Number,
Description = b.Patient,
Value = 0,
Stage = b.Resource,
Status = b.Status == "Done" ? "Posted" : b.Status == "Confirmed" ? "Pending" : "Draft",
Date = b.Date
}).ToList();
// ---- Medical Record raw data (top 1000) ----
var mrRaw = await _context.MedicalRecord
.OrderByDescending(x => x.CreatedAt)
.Take(1000)
.Select(x => new
{
x.Description,
x.Status,
CategoryName = x.MedicalRecordCategory != null ? x.MedicalRecordCategory.Name : null,
GroupName = x.MedicalRecordGroup != null ? x.MedicalRecordGroup.Name : null,
PatientName = x.Patient != null ? x.Patient.Name : null
})
.ToListAsync(ct);
// Sales activities (medical records grouped by category in memory)
var activityColorsArr = new[] { "#10b981", "#e11d48", "#e11d48", "#6366f1" };
var salesActivities = mrRaw
.GroupBy(x => string.IsNullOrEmpty(x.CategoryName) ? "General" : x.CategoryName)
.OrderByDescending(g => g.Count())
.Take(4)
.Select((g, i) => new SalesActivityDto
{
Category = g.Key,
Count = g.Count(),
Color = i < activityColorsArr.Length ? activityColorsArr[i] : "#94a3b8"
})
.ToList();
// Appointment activities (top 4 from materialized)
var medicalRecords = mrRaw
.Take(4)
.Select(x => new AppointmentActivityDto
{
Type = string.IsNullOrEmpty(x.CategoryName) ? "Checkup" : x.CategoryName,
PatientName = x.PatientName ?? "Patient",
Detail = x.Description ?? "",
ChipLabel = x.Status.ToString(),
ChipColor = x.Status == MedicalRecordStatus.Completed ? "chip-green" : "chip-indigo",
AvatarBg = x.Status == MedicalRecordStatus.Completed ? "#10b981" : "#6366f1",
AvatarText = (string.IsNullOrEmpty(x.GroupName) ? "MR" : x.GroupName).Substring(0, Math.Min(string.IsNullOrEmpty(x.GroupName) ? 2 : x.GroupName.Length, 2)).ToUpper()
}).ToList();
var activityFeeds = medicalRecords.Select(mr => new ActivityFeedDto
{
Title = mr.Type ?? "Medical Record",
Detail = !string.IsNullOrEmpty(mr.PatientName) ? $"Patient: {mr.PatientName}" : "",
TimeAgo = mr.ChipLabel ?? "",
ChipLabel = mr.ChipLabel,
ChipColor = mr.ChipColor,
AvatarBg = mr.AvatarBg,
AvatarText = mr.AvatarText
}).ToList();
// ---- Revenue Trend: top 1000 SalesOrder + PurchaseOrder, materialize, process in memory ----
var soDatesRaw = await _context.SalesOrder
.OrderByDescending(x => x.OrderDate)
.Take(1000)
.Select(x => new { x.OrderDate, Amount = x.AfterTaxAmount ?? 0 })
.ToListAsync(ct);
var poDatesRaw = await _context.PurchaseOrder
.OrderByDescending(x => x.OrderDate)
.Take(1000)
.Select(x => new { x.OrderDate, Amount = x.AfterTaxAmount ?? 0 })
.ToListAsync(ct);
var allSalesByMonth = soDatesRaw
.Where(x => x.OrderDate.HasValue)
.GroupBy(x => new { x.OrderDate!.Value.Year, x.OrderDate.Value.Month })
.OrderByDescending(g => g.Key.Year).ThenByDescending(g => g.Key.Month)
.Take(6)
.Reverse()
.Select(g => new { g.Key.Year, g.Key.Month, Sales = g.Sum(x => (double)x.Amount) })
.ToList();
var allPurchaseByMonth = poDatesRaw
.Where(x => x.OrderDate.HasValue)
.GroupBy(x => new { x.OrderDate!.Value.Year, x.OrderDate.Value.Month })
.ToList();
var monthlyLabels = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
var revenueTrend = allSalesByMonth
.Select(ym =>
{
var purchaseAmount = allPurchaseByMonth
.Where(g => g.Key.Year == ym.Year && g.Key.Month == ym.Month)
.Sum(g => g.Sum(x => (double)x.Amount));
return new ChartDataPoint
{
Label = monthlyLabels[ym.Month - 1],
SalesAmount = Math.Round(ym.Sales / 1000.0, 1),
PurchaseAmount = Math.Round(purchaseAmount / 1000.0, 1)
};
}).ToList();
// ---- MRR ----
var mrr = await _context.Invoice
.Where(x => x.InvoiceStatus == InvoiceStatus.Confirmed && x.CreatedAt >= start)
.SumAsync(x => x.SalesOrder != null ? x.SalesOrder.AfterTaxAmount ?? 0 : 0, ct);
// ---- Conversion metrics ----
var invoicesPaid = await _context.Invoice.CountAsync(x => x.InvoiceStatus == InvoiceStatus.FullPaid, ct);
var invoicesConfirmed = await _context.Invoice.CountAsync(x => x.InvoiceStatus == InvoiceStatus.Confirmed, ct);
var conversionMetrics = new List<ConversionMetricDto>
{
new() { Label = "Patients->Booked", Current = activeBookings, Target = totalPatients, PctValue = totalPatients > 0 ? Math.Round((double)activeBookings / totalPatients * 100, 1) : 0, BarColor = "#2563eb" },
new() { Label = "Booked->Done", Current = completedBookings, Target = totalBookings > 0 ? totalBookings : 1, PctValue = totalBookings > 0 ? Math.Round((double)completedBookings / totalBookings * 100, 1) : 0, BarColor = "#f97316" },
new() { Label = "Invoice->Paid", Current = invoicesPaid, Target = invoicesConfirmed, PctValue = invoicesConfirmed > 0 ? Math.Round((double)invoicesPaid / invoicesConfirmed * 100, 1) : 0, BarColor = "#ec4899" },
new() { Label = "Overall CVR", Current = completedBookings, Target = totalPatients, PctValue = totalPatients > 0 ? Math.Round((double)completedBookings / totalPatients * 100, 1) : 0, BarColor = "#10b981" }
};
// ---- Pipeline summary ----
var pipelineSummaryList = new List<PipelineSummaryDto>
{
new() { Label = "Total Bookings", Value = totalBookings, FormattedValue = totalBookings.ToString("N0"), IconColorClass = "gc1" },
new() { Label = "Completed", Value = completedBookings, FormattedValue = completedBookings.ToString("N0"), IconColorClass = "gc10" },
new() { Label = "Avg Booking", Value = 0, FormattedValue = "0", IconColorClass = "gc2" }
};
// ---- MTD values ----
var salesMTD = soDatesRaw
.Where(x => x.OrderDate >= start)
.Sum(x => x.Amount);
var totalReceivable = await _context.Invoice
.Where(x => x.InvoiceStatus == InvoiceStatus.Confirmed)
.SumAsync(x => x.SalesOrder != null ? x.SalesOrder.AfterTaxAmount ?? 0 : 0, ct);
// ---- Revenue metrics ----
var arBalance = await _context.Invoice
.Where(x => x.InvoiceStatus != InvoiceStatus.FullPaid && x.InvoiceStatus == InvoiceStatus.Confirmed)
.SumAsync(x => x.SalesOrder != null ? x.SalesOrder.AfterTaxAmount ?? 0 : 0, ct);
var collectionRate = invoicesConfirmed > 0 ? Math.Round((double)invoicesPaid / invoicesConfirmed * 100, 1) : 0;
var revenueMetrics = new List<RevenueMetricDto>
{
new() { Label = "MTD Revenue", Value = salesMTD, FormattedValue = DashboardScmResponse.FormatCurrency(salesMTD), Color = "#2563eb" },
new() { Label = "Total Receivable", Value = totalReceivable, FormattedValue = DashboardScmResponse.FormatCurrency(totalReceivable), Color = "#f97316" },
new() { Label = "AR Balance", Value = arBalance, FormattedValue = DashboardScmResponse.FormatCurrency(arBalance), Color = "#e11d48" },
new() { Label = "Collection Rate", Value = (decimal)collectionRate, FormattedValue = collectionRate.ToString("F1") + "%", Color = "#10b981" }
};
// ---- Computed rates ----
var overallConversionRate = totalPatients > 0 ? Math.Round((double)completedBookings / totalPatients * 100, 1) : 0;
var slaPercentage = totalBookings > 0 ? Math.Round((double)completedBookings / totalBookings * 100, 1) : 94.2;
var enterprisePercentage = totalPatients > 0 ? Math.Round((double)(await _context.Patient.CountAsync(x => x.PatientCategory != null && x.PatientCategory.Name == "VIP", ct)) / totalPatients * 100, 1) : 35;
// ---- Cashflow MTD ----
var cashflowInflow = await _context.PaymentReceive
.Where(x => x.PaymentDate >= start)
.SumAsync(x => x.PaymentAmount ?? 0, ct);
var cashflowOutflow = await _context.PaymentDisburse
.Where(x => x.PaymentDate >= start)
.SumAsync(x => x.PaymentAmount ?? 0, ct);
var res = new DashboardScmResponse
{
// Original properties
PendingSO = await _context.SalesOrder.CountAsync(x => x.OrderStatus == SalesOrderStatus.Draft, ct),
PendingPO = await _context.PurchaseOrder.CountAsync(x => x.OrderStatus == PurchaseOrderStatus.Draft, ct),
PendingInvoice = await _context.Invoice.CountAsync(x => x.InvoiceStatus == InvoiceStatus.Draft, ct),
PendingBill = await _context.Bill.CountAsync(x => x.BillStatus == BillStatus.Draft, ct),
TotalCustomer = totalPatients,
TotalVendor = await _context.Vendor.CountAsync(ct),
SalesMTD = salesMTD,
PurchaseMTD = await _context.PurchaseOrder.Where(x => x.OrderDate >= start).SumAsync(x => x.AfterTaxAmount ?? 0, ct),
TotalReceivable = totalReceivable,
TotalPayable = await _context.Bill.Where(x => x.BillStatus == BillStatus.Confirmed).SumAsync(x => x.PurchaseOrder != null ? x.PurchaseOrder.AfterTaxAmount ?? 0 : 0, ct),
DocGrid = docGrid,
GlobalFlowLog = globalLogs,
TopProductsByQty = topProds,
CustGroupStats = patientGroupStats.Select(p => new ScmMetricDto { Label = p.Label, Value = p.Value }).ToList(),
VendGroupStats = vendGroups,
PayMethodStats = payMethods,
CashFlowMTD = new List<ScmMetricDto> {
new() { Label = "Inflow (Receive)", Value = cashflowInflow.ToString("N0"), Color = "#10b981" },
new() { Label = "Outflow (Disburse)", Value = cashflowOutflow.ToString("N0"), Color = "#ef4444" }
},
// CMS-specific properties
TotalPatients = totalPatients,
TotalBookings = totalBookings,
TotalMedicalRecords = totalMedicalRecords,
ActiveAppointments = activeBookings,
MRR = mrr,
BookingCompletionRate = bookingCompletionRate,
TotalEmployees = totalEmployees,
TotalProducts = totalProducts,
AvgBookingValue = 0,
OverallConversionRate = overallConversionRate,
PatientRetentionRate = 92.4,
SlaPercentage = slaPercentage,
EnterprisePercentage = enterprisePercentage,
TargetCvr = 5.0,
DealStages = bookingStages.Select(b => new DealStageDto { Stage = b.Stage, Count = b.Count, Color = b.Color }).ToList(),
LeadSources = leadSources,
ConversionMetrics = conversionMetrics,
SalesActivities = salesActivities,
RecentDeals = recentDeals,
ActivityFeeds = activityFeeds,
PipelineSummary = pipelineSummaryList,
RevenueTrend = revenueTrend,
PatientDistribution = patientGroupStats,
BookingStages = bookingStages,
RecentBookings = recentBookings,
AppointmentActivities = medicalRecords,
RevenueMetrics = revenueMetrics
};
return res;
}
}
+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 GetScmDashboardQuery());
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<DashboardScmResponse>?> GetScmDashboardStatsAsync()
{
var request = new RestRequest("api/dashboard/oms-stats", Method.Get);
request.AddHeader("Accept", "application/json");
return await ExecuteWithResponseAsync<DashboardScmResponse>(_client, request);
}
}