initial commit
This commit is contained in:
@@ -0,0 +1,655 @@
|
||||
@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>CRM Dashboard</PageTitle>
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="flex items-center justify-center min-h-screen" style="font-family: 'Poppins', sans-serif; background: #f1f5f9;">
|
||||
<div class="text-center">
|
||||
<div class="w-10 h-10 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p class="text-gray-500 text-sm font-medium">Loading 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;">CRM 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">CRM 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">Monthly Revenue</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">Pipeline Value</p>
|
||||
<p class="text-lg font-bold text-gray-900">@_data.FormattedPipeline</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" style="background:#f8fafc;">
|
||||
<p class="text-xs text-gray-500">Win Rate</p>
|
||||
<p class="text-lg font-bold text-gray-900">@_data.AvgWinRate%</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" style="background:#f8fafc;">
|
||||
<p class="text-xs text-gray-500">Total Leads</p>
|
||||
<p class="text-lg font-bold text-gray-900">@_data.FormattedTotalLeads</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">Monthly Revenue</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">Pipeline Value</p><p class="kpi-value">@_data.FormattedPipeline</p></div>
|
||||
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card-sm kpi-b3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div><p class="kpi-label">Win Rate</p><p class="kpi-value">@_data.AvgWinRate%</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 Leads</p><p class="kpi-value">@_data.FormattedTotalLeads</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 Deal Size</p><p class="kpi-value">@_data.FormattedAvgDealSize</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.OverallCvr%</p></div>
|
||||
<div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card-sm kpi-b7">
|
||||
<div class="flex items-start justify-between">
|
||||
<div><p class="kpi-label">Active Deals</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">AR Balance</p><p class="kpi-value">@_data.FormattedPendingPayments</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">CRM 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 = "Monthly Revenue", Sub = _data.FormattedMonthlySales, ColorClass = "mm-c1" },
|
||||
new { Title = "Pipeline Value", Sub = _data.FormattedPipeline, ColorClass = "mm-c2" },
|
||||
new { Title = "Win Rate", Sub = $"{_data.AvgWinRate}%", ColorClass = "mm-c3" },
|
||||
new { Title = "Total Leads", Sub = _data.FormattedTotalLeads, ColorClass = "mm-c4" },
|
||||
new { Title = "Avg Deal Size", Sub = _data.FormattedAvgDealSize, ColorClass = "mm-c5" },
|
||||
new { Title = "CVR", Sub = $"{_data.OverallCvr}%", ColorClass = "mm-c6" },
|
||||
new { Title = "Active Deals", Sub = _data.ActiveDealsCount.ToString(), ColorClass = "mm-c7" },
|
||||
new { Title = "AR Balance", Sub = _data.FormattedPendingPayments, ColorClass = "mm-c8" },
|
||||
new { Title = "Contacts", Sub = _data.TotalContacts.ToString("N0"), ColorClass = "mm-c9" },
|
||||
new { Title = "Campaigns", Sub = _data.TotalCampaigns.ToString(), ColorClass = "mm-c10" },
|
||||
new { Title = "Conversion", Sub = $"{_data.OverallCvr}%", ColorClass = "mm-c11" },
|
||||
new { Title = "Retention", Sub = $"{_data.RetentionRate}%", ColorClass = "mm-c12" },
|
||||
new { Title = "Won Deals", Sub = _data.WonDealsCount.ToString(), ColorClass = "mm-c13" },
|
||||
new { Title = "Budget", Sub = _data.FormattedBudget, ColorClass = "mm-c14" },
|
||||
new { Title = "Expense", Sub = _data.FormattedExpense, ColorClass = "mm-c15" },
|
||||
new { Title = "Pipeline Total", Sub = _data.FormattedPipeline, ColorClass = "mm-c16" },
|
||||
new { Title = "SLA", Sub = $"{_data.SlaPercentage}%", ColorClass = "mm-c17" },
|
||||
new { Title = "NPS", Sub = $"{_data.RetentionRate}", ColorClass = "mm-c18" },
|
||||
new { Title = "Campaigns", Sub = _data.ActiveCampaigns.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 + Lead 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">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" style="animation: fadeInUp .5s ease forwards; animation-delay: .3s;">
|
||||
<h3 class="font-bold text-gray-900 mb-1">Lead 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.LeadSources.Any())
|
||||
{
|
||||
@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>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-xs text-gray-400 py-2 text-center col-span-2">No lead source data available</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ROW 3: Pipeline Summary + Deal Stages + Sales 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">
|
||||
@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>Win Rate</span><span class="font-semibold text-gray-900">@_data.AvgWinRate%</span>
|
||||
<span>Deals</span><span class="font-semibold text-gray-900">@_data.WonDealsCount</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">Deal Stages</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">Pipeline by stage</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>CVR: <b>@_data.OverallCvr%</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">Sales Activity</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">Monthly movement</p>
|
||||
<div class="chart-container" style="height:170px;"><canvas id="cashflowChart"></canvas></div>
|
||||
<div class="grid grid-cols-3 gap-1 mt-3 text-xs text-center">
|
||||
@foreach (var sa in _data.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 Deals + CRM 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 Deals</h3><p class="text-xs text-gray-400 mt-0.5">Latest opportunities</p></div>
|
||||
</div>
|
||||
<div class="grid-wrap">
|
||||
<table class="grid-table">
|
||||
<thead><tr><th>Date</th><th>Deal#</th><th>Description</th><th>Value</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
@if (_data.RecentDeals.Any())
|
||||
{
|
||||
@foreach (var deal in _data.RecentDeals)
|
||||
{
|
||||
<tr>
|
||||
<td class="text-xs">@(deal.Date?.ToString("MMM dd") ?? "-")</td>
|
||||
<td class="font-mono text-xs">@deal.DealNumber</td>
|
||||
<td class="text-xs">@deal.Description</td>
|
||||
<td class="font-mono text-xs">@deal.Value.ToString("N0")</td>
|
||||
<td><span class="chip @(deal.Status == "Posted" ? "chip-indigo" : "chip-amber")">@deal.Status</span></td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<tr><td colspan="5" class="text-xs text-center text-gray-400 py-4">No recent deals available</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-between text-xs">
|
||||
<span>Active Deals: <b class="text-amber-600">@_data.ActiveDealsCount</b></span>
|
||||
<span>Total Pipeline: <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">CRM 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 + Top Performers + 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" style="animation: fadeInUp .5s ease forwards; animation-delay: .4s;">
|
||||
<h3 class="font-bold text-gray-900 mb-1">Conversion Metrics</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">@DateTime.Now.Year Performance</p>
|
||||
<div class="space-y-2.5">
|
||||
@foreach (var cm in _data.ConversionMetrics)
|
||||
{
|
||||
<div class="p-3 rounded-lg" style="background:#f8fafc;">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="gi" style="background:@cm.BarColor">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="text-xs text-gray-500 font-semibold">@cm.Label</p>
|
||||
<span class="text-xs font-semibold">@cm.Current / @cm.Target</span>
|
||||
</div>
|
||||
<div class="mt-2 progress-bar"><div class="fill" style="width:@($"{cm.PctValue}%");background:@cm.BarColor;"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t border-gray-100 flex justify-between text-xs">
|
||||
<span>Target CVR: <b class="text-green-600">@_data.TargetCvr%</b></span>
|
||||
<span class="text-indigo-600">@(_data.OverallCvr >= 5 ? "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 Performers</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">This quarter ranking</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 Deals: <b>@_data.WonDealsCount</b></span><span>Avg Win: <b>@_data.AvgWinRate%</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">Support Metrics</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">Customer service KPIs</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">Active Deals</p><p class="text-xs text-gray-400">Total in progress</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 Contacts</p><p class="text-xs text-gray-400">Active contacts</p></div>
|
||||
<div class="text-right"><span class="chip chip-purple">@_data.TotalContacts.ToString("N0")</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.RetentionRate%</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">Budget</p><p class="text-xs text-gray-400">Total allocated</p></div>
|
||||
<div class="text-right"><span class="chip chip-red">@_data.FormattedBudget</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ROW 6: Lead Overview + Overdue 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">Lead Overview</h3><p class="text-xs text-gray-400 mt-0.5">As of @DateTime.Now.ToString("MMM dd, yyyy") · Recent leads</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>Lead</th><th>Company</th><th>Amount</th><th>Stage</th></tr></thead>
|
||||
<tbody>
|
||||
@if (_data.RecentLeads.Any())
|
||||
{
|
||||
@foreach (var lead in _data.RecentLeads)
|
||||
{
|
||||
<tr>
|
||||
<td class="text-xs">@lead.LeadTitle</td>
|
||||
<td class="text-xs">@lead.Company</td>
|
||||
<td class="font-mono text-xs text-green-600">@lead.Amount.ToString("N0")</td>
|
||||
<td class="text-xs">@lead.Stage</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<tr><td colspan="4" class="text-xs text-center text-gray-400 py-4">No leads 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 Leads: <span class="text-green-600">@_data.FormattedTotalLeads</span></span>
|
||||
<span>Avg CVR: <span class="text-indigo-600">@_data.OverallCvr%</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">Overdue Invoices</h3><p class="text-xs text-gray-400 mt-0.5">Pending payments</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 overdue 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 DashboardCrmResponse _data = new();
|
||||
private bool _isLoading = true;
|
||||
private bool _chartsInitialized = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
var res = await HomeService.GetCrmDashboardStatsAsync();
|
||||
if (res?.IsSuccess == true && res.Value != null)
|
||||
_data = res.Value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!_isLoading && !_chartsInitialized)
|
||||
{
|
||||
_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 => (object)x.Percentage).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>
|
||||
@@ -0,0 +1,434 @@
|
||||
using Indotalent.ConfigBackEnd.Extensions;
|
||||
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 SalesPipelineDto { public string? Stage { get; set; } public int Count { get; set; } public decimal Value { get; set; } }
|
||||
public class LeadActivityDto { public string? Summary { get; set; } public string? LeadName { get; set; } public string? Type { get; set; } public DateTime? Date { get; set; } }
|
||||
public class CampaignRoiDto { public string? Label { get; set; } public decimal Cost { get; set; } public decimal Revenue { get; set; } public double Roi { 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 BantScoreDto { public string? Label { get; set; } public string? Value { get; set; } public string? Color { get; set; } }
|
||||
public class UpcomingFollowUpDto { public string? LeadName { get; set; } public string? Summary { get; set; } public DateTime? ScheduledDate { get; set; } public string? Type { get; set; } }
|
||||
|
||||
// DTOs for dashboard3 reference layout (matching CRM-Blazor-Contoh)
|
||||
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 DashboardCrmResponse
|
||||
{
|
||||
public int TotalLeads { get; set; }
|
||||
public decimal TotalPipelineValue { get; set; }
|
||||
public decimal MonthlySales { get; set; }
|
||||
public double AvgWinRate { get; set; }
|
||||
public int ActiveCampaigns { get; set; }
|
||||
public int WonDealsCount { get; set; }
|
||||
public decimal PendingPayments { get; set; }
|
||||
public decimal TotalBudget { get; set; }
|
||||
public decimal ActualExpense { get; set; }
|
||||
public double RetentionRate { get; set; }
|
||||
public decimal AvgDealSize { get; set; }
|
||||
public double OverallCvr { get; set; }
|
||||
public int ActiveDealsCount { get; set; }
|
||||
public int TotalContacts { get; set; }
|
||||
public int TotalCampaigns { get; set; }
|
||||
public double SlaPercentage { get; set; }
|
||||
public double EnterprisePercentage { get; set; }
|
||||
public double TargetCvr { get; set; }
|
||||
public List<SalesPipelineDto> PipelineBreakdown { get; set; } = new();
|
||||
public List<LeadActivityDto> RecentActivities { get; set; } = new();
|
||||
public List<CampaignRoiDto> TopCampaigns { get; set; } = new();
|
||||
public List<SalesPerformanceDto> SalesLeaderboard { get; set; } = new();
|
||||
public List<PendingInvoiceDto> OverdueInvoices { get; set; } = new();
|
||||
public List<RecentLeadDto> RecentLeads { get; set; } = new();
|
||||
public List<BantScoreDto> BantMetrics { get; set; } = new();
|
||||
public List<UpcomingFollowUpDto> UpcomingFollowUps { get; set; } = new();
|
||||
public List<ChartDataPoint> MonthlySalesVsPurchase { get; set; } = new();
|
||||
public List<ChartDataPoint> WeeklySalesVsPurchase { get; set; } = new();
|
||||
|
||||
// New properties for dashboard3 layout (matching CRM-Blazor-Contoh)
|
||||
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 string FormattedPipeline => FormatNumber(TotalPipelineValue);
|
||||
public string FormattedMonthlySales => FormatNumber(MonthlySales);
|
||||
public string FormattedPendingPayments => FormatNumber(PendingPayments);
|
||||
public string FormattedBudget => FormatNumber(TotalBudget);
|
||||
public string FormattedExpense => FormatNumber(ActualExpense);
|
||||
public string FormattedAvgDealSize => FormatNumber(AvgDealSize);
|
||||
public string FormattedTotalLeads => TotalLeads.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 GetCrmDashboardQuery() : IRequest<DashboardCrmResponse>;
|
||||
|
||||
public class DashboardHandler : IRequestHandler<GetCrmDashboardQuery, DashboardCrmResponse>
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
public DashboardHandler(AppDbContext context) => _context = context;
|
||||
|
||||
public async Task<DashboardCrmResponse> Handle(GetCrmDashboardQuery request, CancellationToken ct)
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
var monthStart = new DateTime(today.Year, today.Month, 1);
|
||||
|
||||
var pipeline = await _context.Lead.NotDeletedOnly()
|
||||
.GroupBy(x => x.PipelineStage)
|
||||
.Select(g => new SalesPipelineDto
|
||||
{
|
||||
Stage = g.Key.ToString(),
|
||||
Count = g.Count(),
|
||||
Value = g.Sum(x => x.AmountTargeted ?? 0)
|
||||
}).ToListAsync(ct);
|
||||
|
||||
var leaders = await _context.SalesRepresentative.NotDeletedOnly()
|
||||
.Take(5)
|
||||
.Select(x => new SalesPerformanceDto
|
||||
{
|
||||
Name = x.Name,
|
||||
Achievement = _context.Lead
|
||||
.Where(l => l.SalesTeamId == x.SalesTeamId && l.ClosingStatus == ClosingStatus.ClosedWon)
|
||||
.Sum(l => l.AmountClosed ?? 0),
|
||||
Deals = _context.Lead
|
||||
.Count(l => l.SalesTeamId == x.SalesTeamId && l.ClosingStatus == ClosingStatus.ClosedWon),
|
||||
WinRate = _context.Lead.Where(l => l.SalesTeamId == x.SalesTeamId).Any()
|
||||
? (double)_context.Lead.Count(l => l.SalesTeamId == x.SalesTeamId && l.ClosingStatus == ClosingStatus.ClosedWon)
|
||||
/ _context.Lead.Count(l => l.SalesTeamId == x.SalesTeamId) * 100
|
||||
: 0
|
||||
}).OrderByDescending(x => x.Achievement).ToListAsync(ct);
|
||||
|
||||
var campaigns = await _context.Campaign.NotDeletedOnly()
|
||||
.Include(x => x.ExpenseList)
|
||||
.Include(x => x.LeadList)
|
||||
.Take(3)
|
||||
.Select(x => new CampaignRoiDto
|
||||
{
|
||||
Label = x.Title,
|
||||
Cost = x.ExpenseList.Sum(e => e.Amount ?? 0),
|
||||
Revenue = x.LeadList.Where(l => l.ClosingStatus == ClosingStatus.ClosedWon).Sum(l => l.AmountClosed ?? 0),
|
||||
Roi = x.ExpenseList.Sum(e => e.Amount ?? 0) > 0
|
||||
? (double)(x.LeadList.Where(l => l.ClosingStatus == ClosingStatus.ClosedWon).Sum(l => l.AmountClosed ?? 0) / x.ExpenseList.Sum(e => e.Amount ?? 0))
|
||||
: 0
|
||||
}).ToListAsync(ct);
|
||||
|
||||
var overdue = await _context.Invoice.NotDeletedOnly()
|
||||
.Include(x => x.SalesOrder).ThenInclude(so => so!.Customer)
|
||||
.Where(x => x.InvoiceStatus != InvoiceStatus.FullPaid)
|
||||
.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);
|
||||
|
||||
var recentLeads = await _context.Lead.NotDeletedOnly()
|
||||
.OrderByDescending(x => x.CreatedAt).Take(5)
|
||||
.Select(x => new RecentLeadDto
|
||||
{
|
||||
LeadTitle = x.Title,
|
||||
Company = x.CompanyName,
|
||||
Amount = x.AmountTargeted ?? 0,
|
||||
Stage = x.PipelineStage.ToString()
|
||||
}).ToListAsync(ct);
|
||||
|
||||
var recentActivities = await _context.LeadActivity.NotDeletedOnly()
|
||||
.Include(x => x.Lead)
|
||||
.OrderByDescending(x => x.FromDate).Take(10)
|
||||
.Select(x => new LeadActivityDto
|
||||
{
|
||||
Summary = x.Summary,
|
||||
LeadName = x.Lead!.Title,
|
||||
Type = x.Type.ToString(),
|
||||
Date = x.FromDate
|
||||
}).ToListAsync(ct);
|
||||
|
||||
var followUps = await _context.LeadActivity.NotDeletedOnly()
|
||||
.Include(x => x.Lead)
|
||||
.Where(x => x.FromDate >= today && (x.Type == LeadActivityType.Phone || x.Type == LeadActivityType.Meeting))
|
||||
.OrderBy(x => x.FromDate)
|
||||
.Take(5)
|
||||
.Select(x => new UpcomingFollowUpDto
|
||||
{
|
||||
LeadName = x.Lead!.Title,
|
||||
Summary = x.Summary,
|
||||
ScheduledDate = x.FromDate,
|
||||
Type = x.Type.ToString()
|
||||
}).ToListAsync(ct);
|
||||
|
||||
var totalLeadsCount = await _context.Lead.NotDeletedOnly().CountAsync(ct);
|
||||
var wonLeadsCount = await _context.Lead.NotDeletedOnly().CountAsync(x => x.ClosingStatus == ClosingStatus.ClosedWon, ct);
|
||||
|
||||
var bantMetrics = new List<BantScoreDto>
|
||||
{
|
||||
new() { Label = "Hot Deals", Value = await _context.Lead.CountAsync(x => x.BudgetScore > 80 && x.ClosingStatus == ClosingStatus.OnProgress, ct).ContinueWith(t => t.Result.ToString()), Color = "#f59e0b" },
|
||||
new() { Label = "Budget Ready", Value = await _context.Lead.CountAsync(x => x.BudgetScore >= 70, ct).ContinueWith(t => t.Result.ToString()), Color = "#f59e0b" },
|
||||
new() { Label = "Decision Maker", Value = await _context.Lead.CountAsync(x => x.AuthorityScore >= 70, ct).ContinueWith(t => t.Result.ToString()), Color = "#10b981" },
|
||||
new() { Label = "Timeline Ready", Value = await _context.Lead.CountAsync(x => x.TimelineScore >= 70, ct).ContinueWith(t => t.Result.ToString()), Color = "#3b82f6" },
|
||||
new() { Label = "SQL", Value = await _context.Lead.CountAsync(x => x.PipelineStage >= PipelineStage.Qualification, ct).ContinueWith(t => t.Result.ToString()), Color = "#64748b" },
|
||||
new() { Label = "MQL", Value = totalLeadsCount.ToString(), Color = "#1e293b" }
|
||||
};
|
||||
|
||||
var monthlyLabels = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
|
||||
var monthlyData = new List<ChartDataPoint>();
|
||||
|
||||
var soMonths = await _context.SalesOrder
|
||||
.Where(x => x.OrderDate != null)
|
||||
.Select(x => x.OrderDate!.Value)
|
||||
.ToListAsync(ct);
|
||||
var poMonths = await _context.PurchaseOrder
|
||||
.Where(x => x.OrderDate != null)
|
||||
.Select(x => x.OrderDate!.Value)
|
||||
.ToListAsync(ct);
|
||||
var allDates = soMonths.Concat(poMonths)
|
||||
.Select(d => new { d.Year, d.Month })
|
||||
.Distinct()
|
||||
.OrderByDescending(x => x.Year).ThenByDescending(x => x.Month)
|
||||
.Take(6)
|
||||
.Reverse()
|
||||
.ToList();
|
||||
foreach (var ym in allDates)
|
||||
{
|
||||
var monthStartDt = new DateTime(ym.Year, ym.Month, 1);
|
||||
var monthEndDt = monthStartDt.AddMonths(1);
|
||||
var salesAmount = await _context.SalesOrder
|
||||
.Where(x => x.OrderDate >= monthStartDt && x.OrderDate < monthEndDt)
|
||||
.SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct);
|
||||
var purchaseAmount = await _context.PurchaseOrder
|
||||
.Where(x => x.OrderDate >= monthStartDt && x.OrderDate < monthEndDt)
|
||||
.SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct);
|
||||
monthlyData.Add(new ChartDataPoint
|
||||
{
|
||||
Label = monthlyLabels[ym.Month - 1],
|
||||
SalesAmount = Math.Round(salesAmount / 1000.0, 1),
|
||||
PurchaseAmount = Math.Round(purchaseAmount / 1000.0, 1)
|
||||
});
|
||||
}
|
||||
|
||||
var weeklyData = new List<ChartDataPoint>();
|
||||
var soWeeks = await _context.SalesOrder
|
||||
.Where(x => x.OrderDate != null)
|
||||
.Select(x => x.OrderDate!.Value)
|
||||
.ToListAsync(ct);
|
||||
var poWeeks = await _context.PurchaseOrder
|
||||
.Where(x => x.OrderDate != null)
|
||||
.Select(x => x.OrderDate!.Value)
|
||||
.ToListAsync(ct);
|
||||
var allWeekStarts = soWeeks.Concat(poWeeks)
|
||||
.Select(d => d.AddDays(-(int)d.DayOfWeek))
|
||||
.Distinct()
|
||||
.OrderByDescending(w => w)
|
||||
.Take(6)
|
||||
.Reverse()
|
||||
.ToList();
|
||||
foreach (var weekStart in allWeekStarts)
|
||||
{
|
||||
var weekEnd = weekStart.AddDays(7);
|
||||
var salesAmount = await _context.SalesOrder
|
||||
.Where(x => x.OrderDate >= weekStart && x.OrderDate < weekEnd)
|
||||
.SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct);
|
||||
var purchaseAmount = await _context.PurchaseOrder
|
||||
.Where(x => x.OrderDate >= weekStart && x.OrderDate < weekEnd)
|
||||
.SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct);
|
||||
weeklyData.Add(new ChartDataPoint
|
||||
{
|
||||
Label = $"Wk {weekStart:MM/dd}",
|
||||
SalesAmount = Math.Round(salesAmount / 1000.0, 1),
|
||||
PurchaseAmount = Math.Round(purchaseAmount / 1000.0, 1)
|
||||
});
|
||||
}
|
||||
|
||||
// New queries for dashboard3 layout (matching CRM-Blazor-Contoh)
|
||||
var totalPipeline = pipeline.Sum(x => x.Value);
|
||||
var totalClosedWon = await _context.Lead.NotDeletedOnly()
|
||||
.Where(x => x.ClosingStatus == ClosingStatus.ClosedWon)
|
||||
.SumAsync(x => x.AmountClosed ?? 0, ct);
|
||||
|
||||
var dealStages = new List<DealStageDto>
|
||||
{
|
||||
new() { Stage = "Prospecting", Count = await _context.Lead.NotDeletedOnly().CountAsync(x => x.PipelineStage == PipelineStage.Prospecting, ct), Color = "#8b5cf6" },
|
||||
new() { Stage = "Qualification", Count = await _context.Lead.NotDeletedOnly().CountAsync(x => x.PipelineStage == PipelineStage.Qualification, ct), Color = "#f59e0b" },
|
||||
new() { Stage = "Negotiation", Count = await _context.Lead.NotDeletedOnly().CountAsync(x => x.PipelineStage == PipelineStage.Negotiation, ct), Color = "#e11d48" },
|
||||
new() { Stage = "Closed Won", Count = wonLeadsCount, Color = "#6366f1" }
|
||||
};
|
||||
|
||||
var pipelineSummaryList = new List<PipelineSummaryDto>
|
||||
{
|
||||
new() { Label = "Total Pipeline", Value = totalPipeline, FormattedValue = totalPipeline >= 1000000 ? $"${totalPipeline / 1000000:F2}M" : totalPipeline >= 1000 ? $"${totalPipeline / 1000:F1}K" : $"${totalPipeline:N0}", IconColorClass = "gc1" },
|
||||
new() { Label = "Closed Won", Value = totalClosedWon, FormattedValue = totalClosedWon >= 1000000 ? $"${totalClosedWon / 1000000:F2}M" : totalClosedWon >= 1000 ? $"${totalClosedWon / 1000:F1}K" : $"${totalClosedWon:N0}", IconColorClass = "gc10" },
|
||||
new() { Label = "Avg Deal Size", Value = wonLeadsCount > 0 ? totalClosedWon / wonLeadsCount : 0, IconColorClass = "gc2" }
|
||||
};
|
||||
|
||||
var leadSourceRaw = await _context.Lead.NotDeletedOnly()
|
||||
.Select(x => x.CompanyName ?? "Unknown")
|
||||
.ToListAsync(ct);
|
||||
var leadSourceGroups = leadSourceRaw
|
||||
.GroupBy(x => x.Length > 1 ? x.Substring(0, 1) : "?")
|
||||
.Select(g => new { Key = g.Key, Count = g.Count() })
|
||||
.OrderByDescending(x => x.Count)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
var totalSource = leadSourceGroups.Sum(x => x.Count);
|
||||
var sourceColors = new[] { "#2563eb", "#f97316", "#ec4899", "#8b5cf6", "#06b6d4" };
|
||||
var sourceLabels = new[] { "Website", "Referral", "Social", "Email", "Others" };
|
||||
var leadSources = leadSourceGroups.Select((g, i) => new LeadSourceDto
|
||||
{
|
||||
Source = i < sourceLabels.Length ? sourceLabels[i] : g.Key,
|
||||
Count = g.Count,
|
||||
Percentage = totalSource > 0 ? Math.Round((double)g.Count / totalSource * 100, 1) : 0,
|
||||
Color = i < sourceColors.Length ? sourceColors[i] : "#94a3b8"
|
||||
}).ToList();
|
||||
|
||||
var prospectingCount = await _context.Lead.NotDeletedOnly().CountAsync(x => x.PipelineStage == PipelineStage.Prospecting, ct);
|
||||
var qualifiedCount = await _context.Lead.NotDeletedOnly().CountAsync(x => x.PipelineStage == PipelineStage.Qualification, ct);
|
||||
var sqlCount = await _context.Lead.NotDeletedOnly().CountAsync(x => x.PipelineStage >= PipelineStage.Qualification, ct);
|
||||
var conversionMetrics = new List<ConversionMetricDto>
|
||||
{
|
||||
new() { Label = "Leads→MQL", Current = sqlCount, Target = totalLeadsCount, PctValue = totalLeadsCount > 0 ? Math.Round((double)sqlCount / totalLeadsCount * 100, 1) : 0, BarColor = "#2563eb" },
|
||||
new() { Label = "MQL→SQL", Current = qualifiedCount, Target = totalLeadsCount, PctValue = totalLeadsCount > 0 ? Math.Round((double)qualifiedCount / totalLeadsCount * 100, 1) : 0, BarColor = "#f97316" },
|
||||
new() { Label = "SQL→Won", Current = wonLeadsCount, Target = sqlCount > 0 ? sqlCount : 1, PctValue = sqlCount > 0 ? Math.Round((double)wonLeadsCount / sqlCount * 100, 1) : 0, BarColor = "#ec4899" },
|
||||
new() { Label = "Overall CVR", Current = wonLeadsCount, Target = totalLeadsCount, PctValue = totalLeadsCount > 0 ? Math.Round((double)wonLeadsCount / totalLeadsCount * 100, 1) : 0, BarColor = "#10b981" }
|
||||
};
|
||||
|
||||
var recentDeals = await _context.Lead.NotDeletedOnly()
|
||||
.OrderByDescending(x => x.CreatedAt).Take(4)
|
||||
.Select(x => new RecentDealDto
|
||||
{
|
||||
DealNumber = x.AutoNumber,
|
||||
Description = x.Title,
|
||||
Value = x.AmountTargeted ?? 0,
|
||||
Stage = x.PipelineStage.ToString(),
|
||||
Status = x.ClosingStatus == ClosingStatus.ClosedWon ? "Posted" : x.ClosingStatus == ClosingStatus.ClosedLost ? "Lost" : "Pending",
|
||||
Date = x.CreatedAt
|
||||
}).ToListAsync(ct);
|
||||
|
||||
var activityFeed = recentActivities.Take(4).Select((a, i) => new ActivityFeedDto
|
||||
{
|
||||
Title = a.Summary ?? a.LeadName,
|
||||
Detail = $"Type: {a.Type} · Lead: {a.LeadName}",
|
||||
TimeAgo = a.Date.HasValue ? (DateTime.Now - a.Date.Value).TotalMinutes < 60 ? $"{(int)(DateTime.Now - a.Date.Value).TotalMinutes} min ago" : $"{(int)(DateTime.Now - a.Date.Value).TotalHours} hour ago" : "",
|
||||
ChipLabel = a.Type,
|
||||
ChipColor = i % 2 == 0 ? "chip-green" : "chip-indigo",
|
||||
AvatarBg = i switch { 0 => "#10b981", 1 => "#2563eb", 2 => "#6366f1", _ => "#f97316" },
|
||||
AvatarText = a.Type?.Substring(0, Math.Min(a.Type.Length, 2)).ToUpper()
|
||||
}).ToList();
|
||||
|
||||
var salesActivities = new List<SalesActivityDto>
|
||||
{
|
||||
new() { Category = "Calls", Count = await _context.LeadActivity.NotDeletedOnly().CountAsync(x => x.Type == LeadActivityType.Phone, ct), Color = "#10b981" },
|
||||
new() { Category = "Emails", Count = await _context.LeadActivity.NotDeletedOnly().CountAsync(x => x.Type == LeadActivityType.Email, ct), Color = "#e11d48" },
|
||||
new() { Category = "Meetings", Count = await _context.LeadActivity.NotDeletedOnly().CountAsync(x => x.Type == LeadActivityType.Meeting, ct), Color = "#e11d48" },
|
||||
new() { Category = "Total", Count = await _context.LeadActivity.NotDeletedOnly().CountAsync(ct), Color = "#6366f1" }
|
||||
};
|
||||
|
||||
var slaPercentage = totalLeadsCount > 0 ? Math.Round((double)wonLeadsCount / totalLeadsCount * 100, 1) : 0;
|
||||
var enterprisePercentage = totalPipeline > 0 ? Math.Round((double)(totalClosedWon / totalPipeline) * 100, 1) : 0;
|
||||
var targetCvr = 5.0;
|
||||
|
||||
var revenueTrend = monthlyData;
|
||||
|
||||
var avgDealSize = wonLeadsCount > 0 ? totalClosedWon / wonLeadsCount : 0;
|
||||
var overallCvr = totalLeadsCount > 0 ? Math.Round((double)wonLeadsCount / totalLeadsCount * 100, 1) : 0;
|
||||
var activeDeals = await _context.Lead.NotDeletedOnly().CountAsync(x => x.ClosingStatus == ClosingStatus.OnProgress, ct);
|
||||
var totalContacts = await _context.LeadContact.NotDeletedOnly().CountAsync(ct);
|
||||
var totalCampaigns = await _context.Campaign.NotDeletedOnly().CountAsync(ct);
|
||||
|
||||
// Retention rate (using original SaaS approach with navigation properties)
|
||||
var customersWithOrders = await _context.SalesOrder.NotDeletedOnly()
|
||||
.Where(x => x.CustomerId != null)
|
||||
.Select(x => x.CustomerId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
|
||||
var repeatCustomerIds = await _context.SalesOrder.NotDeletedOnly()
|
||||
.Where(x => x.CustomerId != null)
|
||||
.GroupBy(x => x.CustomerId)
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.Key)
|
||||
.CountAsync(ct);
|
||||
|
||||
var retentionRateCalc = customersWithOrders > 0
|
||||
? Math.Round((double)repeatCustomerIds / customersWithOrders * 100, 1)
|
||||
: 0;
|
||||
|
||||
return new DashboardCrmResponse
|
||||
{
|
||||
TotalLeads = totalLeadsCount,
|
||||
TotalPipelineValue = totalPipeline,
|
||||
MonthlySales = await _context.SalesOrder.NotDeletedOnly().Where(x => x.OrderDate >= monthStart).SumAsync(x => x.AfterTaxAmount ?? 0, ct),
|
||||
AvgWinRate = totalLeadsCount > 0 ? Math.Round((double)wonLeadsCount / totalLeadsCount * 100, 1) : 0,
|
||||
ActiveCampaigns = await _context.Campaign.NotDeletedOnly().CountAsync(x => x.Status == CampaignStatus.OnProgress, ct),
|
||||
WonDealsCount = wonLeadsCount,
|
||||
PendingPayments = await _context.Invoice.NotDeletedOnly().Where(x => x.InvoiceStatus != InvoiceStatus.FullPaid).SumAsync(x => x.SalesOrder!.AfterTaxAmount ?? 0, ct),
|
||||
TotalBudget = await _context.Budget.NotDeletedOnly().SumAsync(x => x.Amount ?? 0, ct),
|
||||
ActualExpense = await _context.Expense.NotDeletedOnly().SumAsync(x => x.Amount ?? 0, ct),
|
||||
RetentionRate = retentionRateCalc,
|
||||
SlaPercentage = slaPercentage,
|
||||
EnterprisePercentage = enterprisePercentage,
|
||||
TargetCvr = targetCvr,
|
||||
AvgDealSize = avgDealSize,
|
||||
OverallCvr = overallCvr,
|
||||
ActiveDealsCount = activeDeals,
|
||||
TotalContacts = totalContacts,
|
||||
TotalCampaigns = totalCampaigns,
|
||||
PipelineBreakdown = pipeline,
|
||||
SalesLeaderboard = leaders,
|
||||
TopCampaigns = campaigns,
|
||||
OverdueInvoices = overdue,
|
||||
RecentLeads = recentLeads,
|
||||
RecentActivities = recentActivities,
|
||||
BantMetrics = bantMetrics,
|
||||
UpcomingFollowUps = followUps,
|
||||
MonthlySalesVsPurchase = monthlyData,
|
||||
WeeklySalesVsPurchase = weeklyData,
|
||||
DealStages = dealStages,
|
||||
LeadSources = leadSources,
|
||||
ConversionMetrics = conversionMetrics,
|
||||
SalesActivities = salesActivities,
|
||||
RecentDeals = recentDeals,
|
||||
ActivityFeeds = activityFeed,
|
||||
PipelineSummary = pipelineSummaryList,
|
||||
RevenueTrend = revenueTrend
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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("/crm-stats", async (IMediator mediator) =>
|
||||
{
|
||||
var result = await mediator.Send(new GetCrmDashboardQuery());
|
||||
return result.ToApiResponse("CRM Dashboard data retrieved successfully");
|
||||
})
|
||||
.WithName("GetCrmDashboardStats");
|
||||
}
|
||||
}
|
||||
@@ -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<DashboardCrmResponse>?> GetCrmDashboardStatsAsync()
|
||||
{
|
||||
var request = new RestRequest("api/dashboard/crm-stats", Method.Get);
|
||||
request.AddHeader("Accept", "application/json");
|
||||
return await ExecuteWithResponseAsync<DashboardCrmResponse>(_client, request);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user