Files
blazor-saas-hrm/Features/Root/Home/Components/DashboardPage.razor
T
2026-07-21 14:22:06 +07:00

606 lines
46 KiB
Plaintext

@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>HRM Dashboard</PageTitle>
<div style="font-family: 'Poppins', sans-serif;">
<div class="p-4 md:p-6 space-y-6">
@* HEADER *@
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3" style="outline:none;">
<div>
<h1 class="text-2xl md:text-3xl font-bold text-gray-900" style="outline:none;">HRM Dashboard</h1>
</div>
</div>
@if (_isLoading)
{
<div class="flex items-center justify-center py-20">
<MudBlazor.MudProgressCircular Color="MudBlazor.Color.Primary" Indeterminate="true" Size="MudBlazor.Size.Large" />
</div>
}
else
{
@* ROW 1: 12-col KPI layout *@
<div class="grid grid-cols-1 lg:grid-cols-12 gap-4">
@* Key Metrics Card (spans 2 rows) *@
<div class="lg:col-span-5 row-span-2 bg-white rounded-xl border border-gray-100 p-6 anim-fade anim-delay-1 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">HRM 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-[10px] text-gray-500">Total Staff</p>
<p class="text-lg font-bold text-gray-900">@_data.TotalStaff.ToString("N0")</p>
<span class="text-[10px] text-green-600 font-semibold">FTE: @_data.FteValue.ToString("F2")</span>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-[10px] text-gray-500">Avg Cost/Hire</p>
<p class="text-lg font-bold text-gray-900">@_data.FormattedAvgCost</p>
<span class="text-[10px] text-green-600 font-semibold">@_data.TotalAppraisals apps</span>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-[10px] text-gray-500">Retention Rate</p>
<p class="text-lg font-bold text-gray-900">@_data.RetentionRate%</p>
<span class="text-[10px] text-green-600 font-semibold">Leavers: @_data.LeaversRate%</span>
</div>
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<p class="text-[10px] text-gray-500">Engagement</p>
<p class="text-lg font-bold text-gray-900">@_data.EngagementRate%</p>
<span class="text-[10px] text-green-600 font-semibold">Survey-based</span>
</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>Payroll: <b class="text-gray-700">@_data.FormattedTotalPayroll</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 anim-fade anim-delay-2">
<div class="kpi-card-sm kpi-b1"><div class="flex items-start justify-between"><div><p class="kpi-label">Total Staff</p><p class="kpi-value">@_data.TotalStaff.ToString("N0")</p></div><div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" 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">Retention</p><p class="kpi-value">@_data.RetentionRate%</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">Engagement</p><p class="kpi-value">@_data.EngagementRate%</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">Branches</p><p class="kpi-value">@_data.TotalBranches</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 anim-fade anim-delay-3">
<div class="kpi-card-sm kpi-b5"><div class="flex items-start justify-between"><div><p class="kpi-label">Avg Cost/Hire</p><p class="kpi-value">@_data.FormattedAvgCost</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">Appraisals</p><p class="kpi-value">@_data.TotalAppraisals.ToString("N0")</p></div><div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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">Leave Reqs</p><p class="kpi-value">@_data.TotalLeaveRequests.ToString("N0")</p></div><div class="kpi-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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">Payroll</p><p class="kpi-value">@_data.FormattedTotalPayroll</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 anim-fade anim-delay-2">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">HRM 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 Staff", Sub = _data.TotalStaff.ToString("N0"), ColorClass = "mm-c1" },
new { Title = "Retention Rate", Sub = $"{_data.RetentionRate}%", ColorClass = "mm-c2" },
new { Title = "Engagement", Sub = $"{_data.EngagementRate}%", ColorClass = "mm-c3" },
new { Title = "Branches", Sub = _data.TotalBranches.ToString(), ColorClass = "mm-c4" },
new { Title = "Avg Cost/Hire", Sub = _data.FormattedAvgCost ?? "-", ColorClass = "mm-c5" },
new { Title = "Departments", Sub = _data.TotalDepartments.ToString(), ColorClass = "mm-c6" },
new { Title = "Transfer Reqs", Sub = _data.TotalTransferRequests.ToString("N0"), ColorClass = "mm-c7" },
new { Title = "Payroll", Sub = _data.FormattedTotalPayroll ?? "0", ColorClass = "mm-c8" },
new { Title = "Appraisals", Sub = _data.TotalAppraisals.ToString("N0"), ColorClass = "mm-c9" },
new { Title = "Promotions", Sub = _data.ActivePromotions.ToString(), ColorClass = "mm-c10" },
new { Title = "Leave Requests", Sub = _data.TotalLeaveRequests.ToString("N0"), ColorClass = "mm-c11" },
new { Title = "Top Talent", Sub = _data.TopTalentCount.ToString(), ColorClass = "mm-c12" },
new { Title = "Core Players", Sub = _data.CorePlayerCount.ToString(), ColorClass = "mm-c13" },
new { Title = "Low Perf.", Sub = _data.LowPerfCount.ToString(), ColorClass = "mm-c14" },
new { Title = "FTE Value", Sub = _data.FteValue.ToString("F2"), ColorClass = "mm-c15" },
new { Title = "EX NPS", Sub = _data.EmployeeExperienceNps.ToString("F1"), ColorClass = "mm-c16" }
};
}
@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: Headcount Trend + Department Distribution *@
<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 anim-fade anim-delay-2">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">Headcount Trend</h3><p class="text-xs text-gray-400 mt-0.5">Monthly staff count vs new hires</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> Staff</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm" style="background:#f97316;"></span> New Hires</span>
</div>
</div>
<div class="chart-container" style="height:260px;"><canvas id="headcountChart"></canvas></div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 anim-fade anim-delay-3">
<h3 class="font-bold text-gray-900 mb-1">Department Distribution</h3>
<p class="text-xs text-gray-400 mb-3">Staff by department</p>
<div class="chart-container" style="height:200px;"><canvas id="deptChart"></canvas></div>
<div class="grid grid-cols-2 gap-1 mt-3 text-[10px]">
@{
var deptList = _data.DeptDistributions?.Take(6).ToList() ?? new List<DeptDistributionDto>();
var doughnutColors = new[] { "#2563eb", "#f97316", "#ec4899", "#8b5cf6", "#06b6d4", "#10b981" };
var deptTotal = deptList.Sum(x => x.Count);
if (deptTotal == 0) deptTotal = 1;
for (int i = 0; i < deptList.Count; i++)
{
var d = deptList[i];
var dpct = (int)Math.Round((double)d.Count / deptTotal * 100);
var dcolor = i < doughnutColors.Length ? doughnutColors[i] : "#94a3b8";
<div class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full" style="background:@dcolor"></span> @d.Department <span class="font-semibold ml-auto">@dpct%</span>
</div>
}
}
@if (!deptList.Any())
{
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-full" style="background:#e2e8f0"></span> No data</div>
}
</div>
</div>
</div>
@* ROW 3: Pipeline Summary + Talent Segments + Leave Breakdown *@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-white rounded-xl border border-gray-100 p-5 anim-fade anim-delay-3">
<h3 class="font-bold text-gray-900 mb-1">HR 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">
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi gc1"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 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">Total Employees</p><p class="text-sm font-bold text-gray-900">@_data.TotalStaff.ToString("N0")</p></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="M19 14l-7 7m0 0l-7-7m7 7V3"/></svg></div>
<div class="flex-1"><p class="text-xs text-gray-500">Appraisals</p><p class="text-sm font-bold text-indigo-700">@_data.TotalAppraisals.ToString("N0")</p></div>
</div>
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi gc2"><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 class="flex-1"><p class="text-xs text-gray-500">Total Payroll</p><p class="text-sm font-bold text-indigo-700">@_data.FormattedTotalPayroll</p></div>
</div>
<div class="flex items-center justify-between text-xs pt-2 border-t border-gray-100">
<span>Retention</span><span class="font-semibold text-gray-900">@_data.RetentionRate%</span>
<span>Engagement</span><span class="font-semibold text-gray-900">@_data.EngagementRate%</span>
</div>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 anim-fade anim-delay-4">
<h3 class="font-bold text-gray-900 mb-1">Talent Segments</h3>
<p class="text-xs text-gray-400 mb-3">Performance by category</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">Top Talent</p><p class="text-sm font-bold text-green-700">@_data.TopTalentCount</p></div>
</div>
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi gc5"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></div>
<div class="flex-1"><p class="text-xs text-gray-500">Core Players</p><p class="text-sm font-bold text-amber-600">@_data.CorePlayerCount</p></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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg></div>
<div class="flex-1"><p class="text-xs text-gray-500">Low Performance</p><p class="text-sm font-bold text-red-600">@_data.LowPerfCount</p></div>
</div>
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi gc9"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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 class="flex-1"><p class="text-xs text-gray-500">Active Promotions</p><p class="text-sm font-bold text-indigo-700">@_data.ActivePromotions</p></div>
</div>
</div>
<div class="mt-2 flex justify-between text-xs"><span>Forecast: <b>@_data.HeadcountForecast FTE</b></span><span>FTE: <b>@_data.FteValue.ToString("F2")</b></span></div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 anim-fade anim-delay-5">
<h3 class="font-bold text-gray-900 mb-1">Leave Type Breakdown</h3>
<p class="text-xs text-gray-400 mb-3">By category this period</p>
<div class="chart-container" style="height:170px;"><canvas id="leaveTypeChart"></canvas></div>
<div class="grid grid-cols-3 gap-1 mt-3 text-[10px] text-center">
@{
var leaveBreakdowns = _data.LeaveTypeBreakdowns?.Take(6).ToList() ?? new List<LeaveTypeBreakdownDto>();
var totalLeaves = leaveBreakdowns.Sum(x => x.Count);
if (totalLeaves == 0) totalLeaves = 1;
foreach (var lb in leaveBreakdowns.Take(6))
{
var lpct = (int)Math.Round((double)lb.Count / totalLeaves * 100);
<div><span class="font-bold text-green-600">+@lb.Count</span><p class="text-gray-400">@lb.Type</p></div>
}
}
</div>
</div>
</div>
@* ROW 4: Recent Appraisals + HRM 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 anim-fade anim-delay-4">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">Recent Appraisals</h3><p class="text-xs text-gray-400 mt-0.5">Latest salary reviews</p></div>
</div>
<div class="grid-wrap">
<table class="grid-table">
<thead><tr><th>Date</th><th>Ref#</th><th>Description</th><th>Value</th><th>Status</th></tr></thead>
<tbody>
@{
var dealList = _data.RecentDeals ?? new List<RecentDealDto>();
}
@if (dealList.Any())
{
@foreach (var deal in dealList)
{
<tr>
<td class="text-xs">@deal.Date</td>
<td class="font-mono text-xs">@deal.Ref</td>
<td class="text-xs">@deal.Description</td>
<td class="font-mono text-xs">@deal.Value</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 appraisal data available</td></tr>
}
</tbody>
</table>
</div>
<div class="mt-3 flex justify-between text-xs">
<span>Active: <b class="text-amber-600">@_data.TotalAppraisals reviews</b></span>
<span>Payroll: <b>@_data.FormattedTotalPayroll</b></span>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 anim-fade anim-delay-5">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">HRM Activity</h3><p class="text-xs text-gray-400 mt-0.5">Real-time HR 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">
@{
var feedList = _data.ActivityFeeds ?? new List<ActivityFeedDto>();
}
@if (feedList.Any())
{
@foreach (var act in feedList)
{
<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:@act.AvatarColor">@act.Initials</div>
<div class="min-w-0 flex-1">
<p class="text-sm text-gray-700"><b>@((MarkupString)(act.Title ?? ""))</b></p>
<p class="text-xs text-gray-400 mt-0.5">@act.Subtitle</p>
<p class="text-xs text-gray-400">@act.TimeAgo</p>
</div>
<span class="chip @(act.ChipClass ?? "chip-indigo") self-start">@act.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: Recruitment Pipeline + Top Performers + Compensation Breakdown *@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-white rounded-xl border border-gray-100 p-5 anim-fade anim-delay-4">
<h3 class="font-bold text-gray-900 mb-1">Recruitment Pipeline</h3>
<p class="text-xs text-gray-400 mb-3">@DateTime.Now.Year performance</p>
<div class="space-y-2.5">
@{
var sourced = _data.PipelineSourced > 0 ? _data.PipelineSourced : 1;
var interviewed = _data.PipelineInterviewed > 0 ? _data.PipelineInterviewed : 1;
var pipelineItems = new[] {
("Sourced", _data.PipelineSourced, _data.PipelineSourced > 0 ? _data.PipelineSourced : 1, "#2563eb"),
("Interviewed", _data.PipelineInterviewed, _data.PipelineSourced > 0 ? _data.PipelineSourced : 1, "#f97316"),
("Offered", _data.PipelineOffered, _data.PipelineInterviewed > 0 ? _data.PipelineInterviewed : 1, "#ec4899"),
("Fill Rate", _data.PipelineOffered, _data.PipelineOffered > 0 ? _data.PipelineOffered : 1, "#10b981")
};
}
@foreach (var (label, val, max, color) in pipelineItems)
{
var pct = max > 0 ? (double)val / max * 100 : 0;
pct = Math.Min(pct, 100);
<div class="p-3 rounded-lg" style="background:#f8fafc;">
<div class="flex items-center gap-3">
<div class="gi" style="background:@color">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-center">
<p class="text-xs text-gray-500 font-semibold">@label</p>
<span class="text-[11px] font-semibold text-green-600">@val</span>
</div>
<div class="mt-2 progress-bar"><div class="fill" style="width:@($"{pct:F0}%");background:@color;"></div></div>
</div>
</div>
</div>
}
</div>
<div class="mt-3 pt-3 border-t border-gray-100 flex justify-between text-xs">
<span>Target: <b class="text-green-600">@_data.HeadcountForecast FTE</b></span>
<span class="text-indigo-600">On Track</span>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 anim-fade anim-delay-5">
<h3 class="font-bold text-gray-900 mb-1">Top Performers</h3>
<p class="text-xs text-gray-400 mb-3">This period ranking</p>
<div class="space-y-2.5">
@{
var perfColors = new[] { "#f97316", "#f59e0b", "#6366f1", "#8b5cf6" };
var performerList = _data.TopPerformers?.Take(4).ToList() ?? new List<StaffPerformanceDto>();
}
@for (int i = 0; i < performerList.Count; i++)
{
var p = performerList[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">KPI: @p.KpiScore</p></div>
<span class="text-xs font-semibold text-green-600">@p.Grade</span>
</div>
}
</div>
<div class="mt-2 flex justify-between text-xs"><span>Top Talent: <b>@_data.TopTalentCount</b></span><span>Avg KPI: <b>@(performerList.Any() ? performerList.Average(x => x.KpiScore).ToString("F0") : "-")</b></span></div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 anim-fade anim-delay-6">
<h3 class="font-bold text-gray-900 mb-1">Compensation Breakdown</h3>
<p class="text-xs text-gray-400 mb-3">Payroll composition</p>
<div class="space-y-2.5">
@{
var compList = _data.CompensationStats ?? new List<CompStatsDto>();
}
@foreach (var stat in compList)
{
<div class="flex items-center gap-3 p-2.5 rounded-lg" style="background:#f8fafc;">
<div class="gi" style="background:@stat.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-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">@stat.Label</p><p class="text-xs text-gray-400">@stat.Value</p></div>
<div class="text-right"><span class="chip @(stat.Color == "#3b82f6" ? "chip-blue" : stat.Color == "#ef4444" ? "chip-red" : "chip-green")">@($"{stat.Percentage:F0}%")</span></div>
</div>
}
@if (!compList.Any())
{
<p class="text-xs text-gray-400">No payroll data available</p>
}
</div>
</div>
</div>
@* ROW 6: Active Transfers + Pending Leave Requests *@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-white rounded-xl border border-gray-100 p-5 anim-fade anim-delay-5">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">Active Transfers</h3><p class="text-xs text-gray-400 mt-0.5">As of @DateTime.Now.ToString("MMM dd, yyyy")</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>Employee</th><th>Location</th><th>Impact</th></tr></thead>
<tbody>
@{
var transferList = _data.RecentTransfers ?? new List<ActiveTransferDto>();
}
@if (transferList.Any())
{
@foreach (var tr in transferList)
{
<tr>
<td class="text-xs font-semibold">@tr.EmployeeName</td>
<td class="text-xs">@tr.ToLocation</td>
<td class="font-mono text-xs text-right font-bold">@tr.ImpactLevel/5</td>
</tr>
}
}
else
{
<tr><td colspan="3" class="text-xs text-center text-gray-400 py-4">No active transfers</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 Transfers: <span class="text-green-600">@_data.TotalTransferRequests.ToString("N0")</span></span>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5 anim-fade anim-delay-6">
<div class="flex items-center justify-between mb-4">
<div><h3 class="font-bold text-gray-900">Latest Leave Requests</h3><p class="text-xs text-gray-400 mt-0.5">Recent submissions</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>Request ID</th><th>Status</th><th>Due</th></tr></thead>
<tbody>
@{
var leaveList = _data.PendingLeaves ?? new List<LeaveTicketDto>();
}
@if (leaveList.Any())
{
@foreach (var lv in leaveList)
{
<tr>
<td class="font-mono text-xs font-semibold">#@lv.TicketId</td>
@{
var chipClass = lv.Status switch
{
"Approved" => "chip-green",
"Rejected" => "chip-red",
"Cancelled" => "chip-red",
_ => "chip-amber"
};
}
<td><span class="chip @chipClass">@lv.Status</span></td>
<td class="text-xs text-right">@lv.DueInfo</td>
</tr>
}
}
else
{
<tr><td colspan="3" class="text-xs text-center text-gray-400 py-4">No leave requests found</td></tr>
}
</tbody>
</table>
</div>
</div>
<div class="mt-3 pt-3 border-t border-gray-100 flex justify-between text-xs">
<span>Total Leaves: <b class="text-gray-900">@_data.TotalLeaveRequests.ToString("N0")</b></span>
<span>Showing: <b class="text-gray-900">@leaveList.Count records</b></span>
</div>
</div>
</div>
}
</div>
</div>
@code {
private DashboardDataResponse _data = new();
private bool _isLoading = true;
private bool _chartsInitialized = false;
protected override async Task OnInitializedAsync()
{
_isLoading = true;
try
{
var res = await HomeService.GetDashboardStatsAsync();
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()
{
try
{
// Headcount Trend Bar Chart
var hcLabels = _data.HeadcountTrends?.Select(x => x.Month).ToList() ?? new List<string>();
await JS.InvokeVoidAsync("HRMCharts.renderBarChart", "headcountChart", hcLabels, new[] {
new { label = "Staff", data = _data.HeadcountTrends?.Select(x => (object)x.Count).ToList() ?? new List<object>(), backgroundColor = "#2563eb", borderRadius = 4, barPercentage = 0.4 },
new { label = "New Hires", data = _data.HeadcountTrends?.Select(x => (object)x.NewHires).ToList() ?? new List<object>(), backgroundColor = "#f97316", borderRadius = 4, barPercentage = 0.4 }
});
// Leave Type Doughnut Chart
var lbList = _data.LeaveTypeBreakdowns ?? new List<LeaveTypeBreakdownDto>();
await JS.InvokeVoidAsync("HRMCharts.renderDoughnutChart", "leaveTypeChart",
lbList.Take(6).Select(x => x.Type).ToList(),
lbList.Take(6).Select(x => (object)x.Count).ToList(),
new[] { "#2563eb", "#f97316", "#ec4899", "#8b5cf6", "#06b6d4", "#10b981" }.Take(lbList.Count).ToList());
// Department Distribution Bar Chart
var deptList2 = _data.DeptDistributions ?? new List<DeptDistributionDto>();
await JS.InvokeVoidAsync("HRMCharts.renderBarChart", "deptChart",
deptList2.Select(x => x.Department).ToList(),
new[] {
new { label = "Staff", data = deptList2.Select(x => (object)x.Count).ToList(), backgroundColor = deptList2.Select((_, i) => (object)(i % 2 == 0 ? "#2563eb" : "#f97316")).ToList(), borderRadius = 4, barPercentage = 0.5 }
});
}
catch { }
}
}
<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; } }
.anim-fade { animation: fadeInUp .5s ease forwards; }
.anim-delay-1 { animation-delay: .1s; }
.anim-delay-2 { animation-delay: .2s; }
.anim-delay-3 { animation-delay: .3s; }
.anim-delay-4 { animation-delay: .4s; }
.anim-delay-5 { animation-delay: .5s; }
.anim-delay-6 { animation-delay: .6s; }
.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-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; }
.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; }
.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; }
.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>