initial commit
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
@using Indotalent.Features.Root
|
||||
@using System.Security.Claims
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<ResourcePreloader />
|
||||
<link rel="icon" type="image/png" href="@Assets["favico.png"]" />
|
||||
<link rel="stylesheet" href="@Assets["Indotalent.styles.css"]" />
|
||||
<ImportMap />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
|
||||
<link href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;450;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
important: true,
|
||||
corePlugins: {
|
||||
preflight: false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
window.HRMCharts = {};
|
||||
window.HRMCharts.renderBarChart = function (canvasId, labels, datasets) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
const existing = Chart.getChart(canvas);
|
||||
if (existing) existing.destroy();
|
||||
const grayText = '#94a3b8';
|
||||
new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: datasets
|
||||
},
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
window.HRMCharts.renderDoughnutChart = function (canvasId, labels, data, colors) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
const existing = Chart.getChart(canvas);
|
||||
if (existing) existing.destroy();
|
||||
new Chart(canvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{ data: data, backgroundColor: colors, borderWidth: 0 }]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '72%',
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
};
|
||||
window.HRMCharts.renderLineChart = function (canvasId, labels, datasets) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
const existing = Chart.getChart(canvas);
|
||||
if (existing) existing.destroy();
|
||||
const grayText = '#94a3b8';
|
||||
new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
grid: { color: '#f1f5f9' },
|
||||
ticks: { color: grayText, font: { size: 10 } }
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: grayText, font: { size: 9 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
|
||||
<ReconnectModal />
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
|
||||
<SsoFirebase/>
|
||||
<SsoKeycloak/>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
@inherits LayoutComponentBase
|
||||
@Body
|
||||
@@ -0,0 +1,59 @@
|
||||
@page "/Error"
|
||||
@layout MainLayout
|
||||
@using System.Diagnostics
|
||||
@using Microsoft.AspNetCore.Mvc
|
||||
@attribute [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
||||
|
||||
<PageTitle>Error - Something Went Wrong</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Medium" Class="d-flex align-center justify-center" Style="height: calc(100vh - 64px);">
|
||||
|
||||
<div class="text-center">
|
||||
|
||||
<MudIcon Icon="@Icons.Material.Filled.Error"
|
||||
Size="Size.Large"
|
||||
Color="Color.Error"
|
||||
Class="mb-6" Style="font-size: 120px;" />
|
||||
|
||||
<MudText Typo="Typo.h3" Class="mb-4 text-error">An Error Occurred</MudText>
|
||||
|
||||
<MudText Typo="Typo.body1" Class="mb-6 text-muted">
|
||||
Sorry, an error occurred while processing your request.<br />
|
||||
Our team has been notified and is working to resolve the issue.
|
||||
</MudText>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<MudPaper Elevation="1" Class="pa-4 mb-6 d-inline-block">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="text-muted">
|
||||
Please provide this ID when contacting support to help us track the issue.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
Href="/"
|
||||
StartIcon="@Icons.Material.Filled.Home">
|
||||
Back to Home
|
||||
</MudButton>
|
||||
|
||||
</div>
|
||||
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
@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>
|
||||
@@ -0,0 +1,567 @@
|
||||
using Indotalent.ConfigBackEnd.Extensions;
|
||||
using Indotalent.Infrastructure.Database;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Indotalent.Features.Root.Home.Cqrs;
|
||||
|
||||
public class SalaryReviewDto
|
||||
{
|
||||
public string? EmployeeName { get; set; }
|
||||
public string? Increment { get; set; }
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
public class CareerPathDto
|
||||
{
|
||||
public string? EmployeeName { get; set; }
|
||||
public string? ToGrade { get; set; }
|
||||
public DateTime? Date { get; set; }
|
||||
}
|
||||
|
||||
public class StaffPerformanceDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Dept { get; set; }
|
||||
public string? LoS { get; set; }
|
||||
public int KpiScore { get; set; }
|
||||
public string? Grade { get; set; }
|
||||
public int ReviewJourney { get; set; }
|
||||
}
|
||||
|
||||
public class ActiveTransferDto
|
||||
{
|
||||
public string? EmployeeName { get; set; }
|
||||
public string? ToLocation { get; set; }
|
||||
public int ImpactLevel { get; set; }
|
||||
}
|
||||
|
||||
public class LeaveTicketDto
|
||||
{
|
||||
public string? TicketId { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? DueInfo { get; set; }
|
||||
}
|
||||
|
||||
public class CompStatsDto
|
||||
{
|
||||
public string? Label { get; set; }
|
||||
public string? Value { get; set; }
|
||||
public double Percentage { get; set; }
|
||||
public string? Color { get; set; }
|
||||
}
|
||||
|
||||
public class CareerBenchmarkDto
|
||||
{
|
||||
public string? EmployeeName { get; set; }
|
||||
public string? Position { get; set; }
|
||||
public double SalaryRatio { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public int StarRating { get; set; }
|
||||
}
|
||||
|
||||
// Chart data DTOs
|
||||
public class HeadcountTrendDto
|
||||
{
|
||||
public string? Month { get; set; }
|
||||
public int Count { get; set; }
|
||||
public int NewHires { get; set; }
|
||||
}
|
||||
|
||||
public class DeptDistributionDto
|
||||
{
|
||||
public string? Department { get; set; }
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
public class LeaveTypeBreakdownDto
|
||||
{
|
||||
public string? Type { get; set; }
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
public class RecentDealDto
|
||||
{
|
||||
public string? Date { get; set; }
|
||||
public string? Ref { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Value { get; set; }
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
public class ActivityFeedDto
|
||||
{
|
||||
public string? Initials { get; set; }
|
||||
public string? AvatarColor { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Subtitle { get; set; }
|
||||
public string? TimeAgo { get; set; }
|
||||
public string? ChipLabel { get; set; }
|
||||
public string? ChipClass { get; set; }
|
||||
}
|
||||
|
||||
public class DashboardDataResponse
|
||||
{
|
||||
public int TotalStaff { get; set; }
|
||||
public double FteValue { get; set; }
|
||||
public decimal AvgCostPerHire { get; set; }
|
||||
public int HeadcountForecast { get; set; }
|
||||
public double RetentionRate { get; set; }
|
||||
public double LeaversRate { get; set; }
|
||||
public double EngagementRate { get; set; }
|
||||
public double EmployeeExperienceNps { get; set; }
|
||||
public double EmployeeExperienceSat { get; set; }
|
||||
public double RoiPercent { get; set; }
|
||||
public double CertifiedPercent { get; set; }
|
||||
public int PipelineSourced { get; set; }
|
||||
public int PipelineInterviewed { get; set; }
|
||||
public int PipelineOffered { get; set; }
|
||||
public int TotalBranches { get; set; }
|
||||
public int TotalDepartments { get; set; }
|
||||
public int TopTalentCount { get; set; }
|
||||
public int CorePlayerCount { get; set; }
|
||||
public int LowPerfCount { get; set; }
|
||||
|
||||
public List<StaffPerformanceDto> TopPerformers { get; set; } = new();
|
||||
public List<ActiveTransferDto> RecentTransfers { get; set; } = new();
|
||||
public List<LeaveTicketDto> PendingLeaves { get; set; } = new();
|
||||
public List<SalaryReviewDto> RecentAppraisals { get; set; } = new();
|
||||
public List<CareerPathDto> UpcomingPromotions { get; set; } = new();
|
||||
public List<CompStatsDto> CompensationStats { get; set; } = new();
|
||||
public List<CareerBenchmarkDto> CareerBenchmarks { get; set; } = new();
|
||||
|
||||
// Chart data
|
||||
public List<HeadcountTrendDto> HeadcountTrends { get; set; } = new();
|
||||
public List<DeptDistributionDto> DeptDistributions { get; set; } = new();
|
||||
public List<LeaveTypeBreakdownDto> LeaveTypeBreakdowns { get; set; } = new();
|
||||
public List<RecentDealDto> RecentDeals { get; set; } = new();
|
||||
public List<ActivityFeedDto> ActivityFeeds { get; set; } = new();
|
||||
|
||||
// Additional KPIs
|
||||
public int TotalLeaveRequests { get; set; }
|
||||
public int TotalAppraisals { get; set; }
|
||||
public int TotalTransferRequests { get; set; }
|
||||
public int ActivePromotions { get; set; }
|
||||
public decimal TotalPayrollAmount { get; set; }
|
||||
|
||||
public string FormattedAvgCost
|
||||
{
|
||||
get
|
||||
{
|
||||
if (AvgCostPerHire >= 1000000)
|
||||
return (AvgCostPerHire / 1000000m).ToString("F1") + "M";
|
||||
if (AvgCostPerHire >= 1000)
|
||||
return (AvgCostPerHire / 1000m).ToString("F1") + "K";
|
||||
return AvgCostPerHire.ToString("C0");
|
||||
}
|
||||
}
|
||||
|
||||
public string FormattedTotalPayroll
|
||||
{
|
||||
get
|
||||
{
|
||||
if (TotalPayrollAmount >= 1000000)
|
||||
return (TotalPayrollAmount / 1000000m).ToString("F1") + "M";
|
||||
if (TotalPayrollAmount >= 1000)
|
||||
return (TotalPayrollAmount / 1000m).ToString("F1") + "K";
|
||||
return TotalPayrollAmount.ToString("N0");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record GetDashboardQuery() : IRequest<DashboardDataResponse>;
|
||||
|
||||
public class DashboardHandler : IRequestHandler<GetDashboardQuery, DashboardDataResponse>
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
public DashboardHandler(AppDbContext context) => _context = context;
|
||||
|
||||
public async Task<DashboardDataResponse> Handle(GetDashboardQuery request, CancellationToken ct)
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
|
||||
// --- Employee counts ---
|
||||
var totalStaff = await _context.Employee.NotDeletedOnly().CountAsync(ct);
|
||||
var fullTimeStaff = await _context.Employee.NotDeletedOnly().CountAsync(x => x.EmploymentType == "Full-Time", ct);
|
||||
double fteValue = totalStaff > 0 ? (double)fullTimeStaff / totalStaff : 0;
|
||||
|
||||
// --- Resigned / Leavers ---
|
||||
var resignedCount = await _context.Employee.NotDeletedOnly().CountAsync(x => x.ResignedDate != null && x.ResignedDate <= today, ct);
|
||||
double retentionRate = totalStaff > 0 ? Math.Round((double)(totalStaff - resignedCount) / totalStaff * 100, 1) : 0;
|
||||
double leaversRate = totalStaff > 0 ? Math.Round((double)resignedCount / totalStaff * 100, 1) : 0;
|
||||
|
||||
// --- Average Cost per Hire (from appraisals) ---
|
||||
var avgSalary = await _context.Appraisal.NotDeletedOnly().AverageAsync(x => (decimal?)x.NewSalary, ct) ?? 0;
|
||||
var forecastCount = await _context.Employee.NotDeletedOnly().CountAsync(x => x.JoinedDate >= today.AddMonths(-3), ct);
|
||||
|
||||
// --- Branches & Departments ---
|
||||
var totalBranches = await _context.Branch.NotDeletedOnly().CountAsync(ct);
|
||||
var totalDepts = await _context.Department.NotDeletedOnly().CountAsync(ct);
|
||||
|
||||
// --- Totals ---
|
||||
var totalLeaveRequests = await _context.LeaveRequest.NotDeletedOnly().CountAsync(ct);
|
||||
var totalAppraisals = await _context.Appraisal.NotDeletedOnly().CountAsync(ct);
|
||||
var totalTransfers = await _context.Transfer.NotDeletedOnly().CountAsync(ct);
|
||||
var activePromotions = await _context.Promotion.NotDeletedOnly()
|
||||
.CountAsync(x => x.Status == "Active" || x.Status == "Pending", ct);
|
||||
|
||||
// --- Evaluation / Performance data ---
|
||||
var allEvaluations = await _context.Evaluation
|
||||
.Include(x => x.Employee).ThenInclude(e => e!.Department)
|
||||
.Include(x => x.Employee).ThenInclude(e => e!.Grade)
|
||||
.NotDeletedOnly()
|
||||
.ToListAsync(ct);
|
||||
|
||||
var rawEvaluationData = allEvaluations
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
var performers = rawEvaluationData.Select(x =>
|
||||
{
|
||||
int.TryParse(x.FinalScore, out var s);
|
||||
int.TryParse(x.Rating, out var r);
|
||||
return new StaffPerformanceDto
|
||||
{
|
||||
Name = $"{x.Employee?.FirstName} {x.Employee?.LastName}",
|
||||
Dept = x.Employee?.Department?.Name ?? "N/A",
|
||||
Grade = x.Employee?.Grade?.Name ?? "-",
|
||||
KpiScore = s,
|
||||
ReviewJourney = r > 0 ? r : 1,
|
||||
LoS = x.Employee?.JoinedDate.HasValue == true ? $"{(today.Year - x.Employee.JoinedDate.Value.Year)}y" : "0y"
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
// --- Engagement Rate: average final score from all evaluations ---
|
||||
var scoredEvaluations = allEvaluations
|
||||
.Select(x => { int.TryParse(x.FinalScore, out var s); return s; })
|
||||
.Where(s => s > 0)
|
||||
.ToList();
|
||||
double engagementRate = scoredEvaluations.Any() ? Math.Round(scoredEvaluations.Average(), 1) : 0;
|
||||
|
||||
// --- Employee Experience (EX): NPS & SAT ---
|
||||
double employeeExperienceNps = 0;
|
||||
double employeeExperienceSat = 0;
|
||||
if (allEvaluations.Any())
|
||||
{
|
||||
var ratingsWithValue = allEvaluations
|
||||
.Select(x => { int.TryParse(x.Rating, out var r); return r; })
|
||||
.Where(r => r > 0)
|
||||
.ToList();
|
||||
employeeExperienceNps = ratingsWithValue.Any()
|
||||
? Math.Round(ratingsWithValue.Average() / 10.0 * 10.0, 1)
|
||||
: 0;
|
||||
employeeExperienceSat = scoredEvaluations.Any() ? Math.Round(scoredEvaluations.Average(), 0) : 0;
|
||||
}
|
||||
|
||||
// --- ROI: derived from appraisal increment percentage average ---
|
||||
var avgIncrementPct = await _context.Appraisal
|
||||
.NotDeletedOnly()
|
||||
.AverageAsync(x => (double?)x.IncrementPercentage, ct) ?? 0;
|
||||
double roiPercent = Math.Round(avgIncrementPct, 1);
|
||||
|
||||
// --- Certified % (Talent Scoring) ---
|
||||
int totalScoredEmployees = scoredEvaluations.Count;
|
||||
int certifiedCount = scoredEvaluations.Count(s => s >= 80);
|
||||
double certifiedPercent = totalScoredEmployees > 0
|
||||
? Math.Round((double)certifiedCount / totalScoredEmployees * 100, 0)
|
||||
: 0;
|
||||
|
||||
// --- Recruitment Pipeline ---
|
||||
var employeesJoinedThisYear = await _context.Employee
|
||||
.NotDeletedOnly()
|
||||
.CountAsync(x => x.JoinedDate >= new DateTime(today.Year, 1, 1), ct);
|
||||
var totalEvaluationsThisYear = allEvaluations.Count(x => x.EvaluationDate >= new DateTime(today.Year, 1, 1));
|
||||
var totalPromotionsThisYear = await _context.Promotion
|
||||
.NotDeletedOnly()
|
||||
.CountAsync(x => x.EffectiveDate >= new DateTime(today.Year, 1, 1), ct);
|
||||
|
||||
int pipelineSourced = employeesJoinedThisYear + totalEvaluationsThisYear;
|
||||
int pipelineInterviewed = totalEvaluationsThisYear;
|
||||
int pipelineOffered = totalPromotionsThisYear;
|
||||
|
||||
// --- Transfers ---
|
||||
var transfers = await _context.Transfer
|
||||
.Include(x => x.Employee).Include(x => x.ToBranch)
|
||||
.NotDeletedOnly()
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Take(5)
|
||||
.Select(x => new ActiveTransferDto
|
||||
{
|
||||
EmployeeName = x.Employee != null ? x.Employee.FirstName : "User",
|
||||
ToLocation = x.ToBranch != null ? x.ToBranch.Name : "Other",
|
||||
ImpactLevel = x.Status == "Processed" ? 5 : 3
|
||||
}).ToListAsync(ct);
|
||||
|
||||
// --- Leave requests (latest 5, any status) ---
|
||||
var leaves = await _context.LeaveRequest
|
||||
.NotDeletedOnly()
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Take(5)
|
||||
.Select(x => new LeaveTicketDto
|
||||
{
|
||||
TicketId = x.AutoNumber,
|
||||
Status = x.Status ?? "Submitted",
|
||||
DueInfo = x.StartDate.ToString("MMM dd")
|
||||
}).ToListAsync(ct);
|
||||
|
||||
// --- Appraisals (materialize first for safe memory projection) ---
|
||||
var recentAppraisals = await _context.Appraisal
|
||||
.Include(x => x.Employee)
|
||||
.NotDeletedOnly()
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Take(3)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var salaryReviews = recentAppraisals.Select(x => new SalaryReviewDto
|
||||
{
|
||||
EmployeeName = x.Employee != null ? x.Employee.FirstName : "Staff",
|
||||
Increment = x.IncrementPercentage.ToString("F1") + "%",
|
||||
Status = x.Status
|
||||
}).ToList();
|
||||
|
||||
// --- Promotions (materialize first) ---
|
||||
var upcomingPromotionsRaw = await _context.Promotion
|
||||
.Include(x => x.Employee)
|
||||
.NotDeletedOnly()
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Take(3)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var promoDtos = upcomingPromotionsRaw.Select(x => new CareerPathDto
|
||||
{
|
||||
EmployeeName = x.Employee != null ? x.Employee.FirstName : "Staff",
|
||||
ToGrade = x.ToGrade,
|
||||
Date = x.EffectiveDate
|
||||
}).ToList();
|
||||
|
||||
// --- Payroll compensation stats ---
|
||||
var latestPayroll = await _context.PayrollProcess
|
||||
.NotDeletedOnly()
|
||||
.OrderByDescending(x => x.ToDate)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
var compStats = new List<CompStatsDto>();
|
||||
if (latestPayroll != null)
|
||||
{
|
||||
var totalGross = latestPayroll.TotalGross > 0 ? latestPayroll.TotalGross : 1;
|
||||
compStats.Add(new CompStatsDto
|
||||
{
|
||||
Label = "Take Home Pay Net",
|
||||
Value = latestPayroll.TotalTakeHomePay.ToString("N0"),
|
||||
Percentage = (double)(latestPayroll.TotalTakeHomePay / totalGross) * 100,
|
||||
Color = "#3b82f6"
|
||||
});
|
||||
compStats.Add(new CompStatsDto
|
||||
{
|
||||
Label = "Tax & Deductions",
|
||||
Value = latestPayroll.TotalDeduction.ToString("N0"),
|
||||
Percentage = (double)(latestPayroll.TotalDeduction / totalGross) * 100,
|
||||
Color = "#ef4444"
|
||||
});
|
||||
compStats.Add(new CompStatsDto
|
||||
{
|
||||
Label = "Incomes/Allowances",
|
||||
Value = latestPayroll.TotalIncome.ToString("N0"),
|
||||
Percentage = (double)(latestPayroll.TotalIncome / totalGross) * 100,
|
||||
Color = "#10b981"
|
||||
});
|
||||
}
|
||||
|
||||
// --- Career benchmarks (materialize first) ---
|
||||
var benchmarks = await _context.Employee
|
||||
.Include(x => x.Grade)
|
||||
.Include(x => x.Designation)
|
||||
.NotDeletedOnly()
|
||||
.Where(x => x.Grade != null)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Take(5)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var careerBenchmarks = benchmarks.Select(x =>
|
||||
{
|
||||
var ratio = x.Grade!.SalaryTo > 0 ? (double)(x.BasicSalary / x.Grade.SalaryTo) * 100 : 0;
|
||||
return new CareerBenchmarkDto
|
||||
{
|
||||
EmployeeName = x.FirstName,
|
||||
Position = x.Designation?.Name ?? "Staff",
|
||||
SalaryRatio = ratio,
|
||||
Status = ratio > 90 ? "Top Range" : ratio > 60 ? "Mid Range" : "Entry",
|
||||
StarRating = ratio > 80 ? 3 : ratio > 40 ? 2 : 1
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
// --- CHART: Headcount Trend (last 6 months) ---
|
||||
var headcountTrends = new List<HeadcountTrendDto>();
|
||||
for (int i = 5; i >= 0; i--)
|
||||
{
|
||||
var monthStart = new DateTime(today.Year, today.Month, 1).AddMonths(-i);
|
||||
var monthEnd = monthStart.AddMonths(1);
|
||||
var count = await _context.Employee.NotDeletedOnly()
|
||||
.CountAsync(x => x.JoinedDate < monthEnd
|
||||
&& (x.ResignedDate == null || x.ResignedDate >= monthStart), ct);
|
||||
var newHires = await _context.Employee.NotDeletedOnly()
|
||||
.CountAsync(x => x.JoinedDate >= monthStart && x.JoinedDate < monthEnd, ct);
|
||||
headcountTrends.Add(new HeadcountTrendDto
|
||||
{
|
||||
Month = monthStart.ToString("MMM"),
|
||||
Count = count,
|
||||
NewHires = newHires
|
||||
});
|
||||
}
|
||||
|
||||
// --- CHART: Department Distribution ---
|
||||
var allDepartments = await _context.Department
|
||||
.NotDeletedOnly()
|
||||
.Select(x => x.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var employeeDeptCounts = await _context.Employee
|
||||
.Include(x => x.Department)
|
||||
.NotDeletedOnly()
|
||||
.Where(x => x.Department != null)
|
||||
.Select(x => new { DeptName = x.Department!.Name })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var deptDistributions = allDepartments
|
||||
.Select(dept => new DeptDistributionDto
|
||||
{
|
||||
Department = dept ?? "Other",
|
||||
Count = employeeDeptCounts.Count(e => e.DeptName == dept)
|
||||
})
|
||||
.OrderByDescending(x => x.Count)
|
||||
.ThenBy(x => x.Department)
|
||||
.ToList();
|
||||
|
||||
// --- CHART: Leave Type Breakdown (materialize first, group in memory) ---
|
||||
var leaveTypeRawNames = await _context.LeaveRequest
|
||||
.Include(x => x.LeaveCategory)
|
||||
.NotDeletedOnly()
|
||||
.Where(x => x.LeaveCategory != null)
|
||||
.Select(x => new { CatName = x.LeaveCategory!.Name })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var leaveTypeBreakdowns = leaveTypeRawNames
|
||||
.GroupBy(x => x.CatName)
|
||||
.Select(g => new LeaveTypeBreakdownDto
|
||||
{
|
||||
Type = g.Key ?? "Other",
|
||||
Count = g.Count()
|
||||
})
|
||||
.OrderByDescending(x => x.Count)
|
||||
.ToList();
|
||||
|
||||
// --- Recent Deals (appraisals as deals) ---
|
||||
var recentDeals = recentAppraisals.Take(5).Select(x => new RecentDealDto
|
||||
{
|
||||
Date = x.CreatedAt?.ToString("MMM dd") ?? "-",
|
||||
Ref = x.AutoNumber ?? "-",
|
||||
Description = (x.Employee?.FirstName ?? "Staff") + " appraisal review",
|
||||
Value = x.NewSalary.ToString("N0"),
|
||||
Status = x.Status ?? "Pending"
|
||||
}).ToList();
|
||||
|
||||
// --- Activity Feed ---
|
||||
var activityFeeds = new List<ActivityFeedDto>();
|
||||
|
||||
if (recentAppraisals.Any())
|
||||
{
|
||||
var a = recentAppraisals.First();
|
||||
activityFeeds.Add(new ActivityFeedDto
|
||||
{
|
||||
Initials = (a.Employee?.FirstName?.Length > 0 ? a.Employee.FirstName[..1] : "S").ToUpper(),
|
||||
AvatarColor = "#10b981",
|
||||
Title = $"<b>Appraisal Completed</b> — {a.Employee?.FirstName ?? "Staff"}",
|
||||
Subtitle = $"📋 Salary: {a.NewSalary:N0} · Increment: {a.IncrementPercentage:F1}%",
|
||||
TimeAgo = "1 hour ago",
|
||||
ChipLabel = a.Status ?? "Completed",
|
||||
ChipClass = "chip-green"
|
||||
});
|
||||
}
|
||||
|
||||
if (upcomingPromotionsRaw.Any())
|
||||
{
|
||||
var p = upcomingPromotionsRaw.First();
|
||||
activityFeeds.Add(new ActivityFeedDto
|
||||
{
|
||||
Initials = (p.Employee?.FirstName?.Length > 0 ? p.Employee.FirstName[..1] : "P").ToUpper(),
|
||||
AvatarColor = "#6366f1",
|
||||
Title = $"<b>Promotion Scheduled</b> — {p.Employee?.FirstName ?? "Staff"}",
|
||||
Subtitle = $"🏷️ To Grade: {p.ToGrade} · Effective: {p.EffectiveDate:dd MMM yyyy}",
|
||||
TimeAgo = "2 hours ago",
|
||||
ChipLabel = "Scheduled",
|
||||
ChipClass = "chip-indigo"
|
||||
});
|
||||
}
|
||||
|
||||
if (leaves.Any())
|
||||
{
|
||||
var l = leaves.First();
|
||||
activityFeeds.Add(new ActivityFeedDto
|
||||
{
|
||||
Initials = "LR",
|
||||
AvatarColor = "#f97316",
|
||||
Title = $"<b>Leave Request Pending</b> — #{l.TicketId}",
|
||||
Subtitle = $"📅 Status: {l.Status} · Due: {l.DueInfo}",
|
||||
TimeAgo = "3 hours ago",
|
||||
ChipLabel = "Pending",
|
||||
ChipClass = "chip-amber"
|
||||
});
|
||||
}
|
||||
|
||||
if (transfers.Any())
|
||||
{
|
||||
var t = transfers.First();
|
||||
activityFeeds.Add(new ActivityFeedDto
|
||||
{
|
||||
Initials = (t.EmployeeName?.Length > 0 ? t.EmployeeName[..1] : "T").ToUpper(),
|
||||
AvatarColor = "#2563eb",
|
||||
Title = $"<b>Transfer Request</b> — {t.EmployeeName}",
|
||||
Subtitle = $"📍 To: {t.ToLocation} · Impact Level: {t.ImpactLevel}/5",
|
||||
TimeAgo = "5 hours ago",
|
||||
ChipLabel = t.ImpactLevel > 3 ? "Processing" : "Pending",
|
||||
ChipClass = t.ImpactLevel > 3 ? "chip-blue" : "chip-indigo"
|
||||
});
|
||||
}
|
||||
|
||||
return new DashboardDataResponse
|
||||
{
|
||||
TotalStaff = totalStaff,
|
||||
FteValue = Math.Round(fteValue, 2),
|
||||
AvgCostPerHire = avgSalary,
|
||||
HeadcountForecast = forecastCount + 5,
|
||||
RetentionRate = retentionRate,
|
||||
LeaversRate = leaversRate,
|
||||
EngagementRate = engagementRate,
|
||||
EmployeeExperienceNps = employeeExperienceNps,
|
||||
EmployeeExperienceSat = employeeExperienceSat,
|
||||
RoiPercent = roiPercent,
|
||||
CertifiedPercent = certifiedPercent,
|
||||
PipelineSourced = pipelineSourced,
|
||||
PipelineInterviewed = pipelineInterviewed,
|
||||
PipelineOffered = pipelineOffered,
|
||||
TotalBranches = totalBranches,
|
||||
TotalDepartments = totalDepts,
|
||||
TotalLeaveRequests = totalLeaveRequests,
|
||||
TotalAppraisals = totalAppraisals,
|
||||
TotalTransferRequests = totalTransfers,
|
||||
ActivePromotions = activePromotions,
|
||||
TotalPayrollAmount = latestPayroll?.TotalGross ?? 0,
|
||||
TopPerformers = performers,
|
||||
RecentTransfers = transfers,
|
||||
PendingLeaves = leaves,
|
||||
RecentAppraisals = salaryReviews,
|
||||
UpcomingPromotions = promoDtos,
|
||||
CompensationStats = compStats,
|
||||
CareerBenchmarks = careerBenchmarks,
|
||||
HeadcountTrends = headcountTrends,
|
||||
DeptDistributions = deptDistributions,
|
||||
LeaveTypeBreakdowns = leaveTypeBreakdowns,
|
||||
RecentDeals = recentDeals,
|
||||
ActivityFeeds = activityFeeds,
|
||||
TopTalentCount = performers.Count(x => x.KpiScore >= 85),
|
||||
CorePlayerCount = performers.Count(x => x.KpiScore < 85 && x.KpiScore >= 60),
|
||||
LowPerfCount = performers.Count(x => x.KpiScore < 60)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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("/stats", async (IMediator mediator) =>
|
||||
{
|
||||
var result = await mediator.Send(new GetDashboardQuery());
|
||||
return result.ToApiResponse("Dashboard data retrieved successfully");
|
||||
})
|
||||
.WithName("GetDashboardStats");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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<DashboardDataResponse>?> GetDashboardStatsAsync()
|
||||
{
|
||||
var request = new RestRequest("api/dashboard/stats", Method.Get);
|
||||
|
||||
request.AddHeader("Accept", "application/json");
|
||||
|
||||
return await ExecuteWithResponseAsync<DashboardDataResponse>(_client, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
@using Indotalent.ConfigBackEnd.Extensions
|
||||
@using Indotalent.ConfigBackEnd.Interfaces
|
||||
@using Indotalent.Infrastructure.Authentication
|
||||
@using Indotalent.Infrastructure.Authorization.Identity
|
||||
@using Indotalent.Shared.Consts
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@inherits LayoutComponentBase
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ICurrentUserService CurrentUserService
|
||||
|
||||
<MudThemeProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
<MudPopoverProvider />
|
||||
|
||||
|
||||
|
||||
|
||||
<MudLayout>
|
||||
<AuthorizeView>
|
||||
<MudAppBar Elevation="1">
|
||||
<MudSpacer />
|
||||
<MudMenu Icon="@Icons.Material.Filled.MoreVert"
|
||||
Color="Color.Inherit"
|
||||
Dense="true"
|
||||
ActivationEvent="@MouseEvent.LeftClick"
|
||||
AnchorOrigin="Origin.BottomLeft"
|
||||
TransformOrigin="Origin.TopLeft"
|
||||
PopoverClass="custom-menu-popover">
|
||||
|
||||
<MudMenuItem OnClick="GoToProfile"
|
||||
Icon="@Icons.Material.Filled.Person"
|
||||
IconColor="Color.Primary">
|
||||
Profile
|
||||
</MudMenuItem>
|
||||
|
||||
<MudMenuItem OnClick="GoToTenantSelection"
|
||||
Icon="@Icons.Material.Filled.Business"
|
||||
IconColor="Color.Info">
|
||||
Change Tenant
|
||||
</MudMenuItem>
|
||||
|
||||
<MudMenuItem OnClick="Logout"
|
||||
Icon="@Icons.Material.Filled.Logout"
|
||||
IconColor="Color.Error"
|
||||
Class="red-text-item">
|
||||
<MudText Color="Color.Error">Logout</MudText>
|
||||
</MudMenuItem>
|
||||
|
||||
</MudMenu>
|
||||
</MudAppBar>
|
||||
|
||||
<MudDrawer @bind-Open="_drawerOpen"
|
||||
Elevation="0"
|
||||
Variant="@DrawerVariant.Mini"
|
||||
OpenMiniOnHover="false"
|
||||
MiniWidth="80px"
|
||||
Width="280px"
|
||||
Class="v6-drawer">
|
||||
|
||||
<MudIconButton Icon="@(_drawerOpen? Icons.Material.Rounded.ChevronLeft : Icons.Material.Rounded.ChevronRight)"
|
||||
OnClick="@DrawerToggle"
|
||||
Class="v6-toggle-btn"
|
||||
Size="Size.Small" />
|
||||
|
||||
<MudDrawerHeader Class="pa-6 mb-2">
|
||||
<div class="d-flex align-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Apps" Size="Size.Medium" Color="Color.Primary" />
|
||||
@if (_drawerOpen)
|
||||
{
|
||||
<MudText Typo="Typo.h5" Class="ml-3" Style="font-weight:900">@GlobalConsts.AppInitial</MudText>
|
||||
}
|
||||
</div>
|
||||
</MudDrawerHeader>
|
||||
|
||||
|
||||
<MudNavMenu Class="v6-nav">
|
||||
@* Member & Admin *@
|
||||
<AuthorizeView Roles="@($"{ApplicationRoles.Admin},{ApplicationRoles.Member}")" Context="commonAuth">
|
||||
<Authorized>
|
||||
<MudNavLink Href="/home" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Home">@(_drawerOpen ? "Home" : "")</MudNavLink>
|
||||
<MudNavLink Href="/profile" Icon="@Icons.Material.Filled.AccountCircle">@(_drawerOpen ? "Profile" : "")</MudNavLink>
|
||||
<MudNavLink Href="/account/tenant-selection" Icon="@Icons.Material.Filled.Business">@(_drawerOpen ? "Change Tenant" : "")</MudNavLink>
|
||||
<MudNavLink Href="/leave" Icon="@Icons.Material.Filled.EventAvailable">@(_drawerOpen ? "Leave Management" : "")</MudNavLink>
|
||||
<MudNavLink Href="/performance" Icon="@Icons.Material.Filled.Speed">@(_drawerOpen ? "Performance" : "")</MudNavLink>
|
||||
<MudNavLink Href="/organization" Icon="@Icons.Material.Filled.AccountTree">@(_drawerOpen ? "Organization" : "")</MudNavLink>
|
||||
<MudNavLink Href="/payroll" Icon="@Icons.Material.Filled.CurrencyExchange">@(_drawerOpen ? "Payroll" : "")</MudNavLink>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Tenant Admin Only *@
|
||||
<AuthorizeView Roles="@ApplicationRoles.TenantAdmin" Context="tenantAdminAuth">
|
||||
<Authorized>
|
||||
<MudNavLink Href="/setting" Icon="@Icons.Material.Filled.Settings">@(_drawerOpen ? "App Settings" : "")</MudNavLink>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Admin Only *@
|
||||
<AuthorizeView Roles="@ApplicationRoles.Admin" Context="adminAuth">
|
||||
<Authorized>
|
||||
<MudNavLink Href="/multitenant" Icon="@Icons.Material.Filled.CorporateFare">@(_drawerOpen ? "Multitenant" : "")</MudNavLink>
|
||||
<MudNavLink Href="/serilogs" Icon="@Icons.Material.Filled.HistoryEdu">@(_drawerOpen ? "System Logs" : "")</MudNavLink>
|
||||
<MudNavLink Href="/appsettings/json" Icon="@Icons.Material.Filled.SettingsEthernet">@(_drawerOpen ? "System Settings" : "")</MudNavLink>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</MudNavMenu>
|
||||
|
||||
<div class="v6-footer d-flex align-center @(_drawerOpen ? "" : "justify-center")">
|
||||
<MudBadge Color="Color.Success" Overlap="true" Dot="true" Bordered="true" Placement="Placement.BottomRight">
|
||||
<MudAvatar Color="Color.Primary" Size="Size.Medium">@CurrentUserService.FullName.ToInitial()</MudAvatar>
|
||||
</MudBadge>
|
||||
@if (_drawerOpen)
|
||||
{
|
||||
<div class="ml-3">
|
||||
<MudText Typo="Typo.caption" Style="font-weight:700; display: block; color: #1e293b;">@CurrentUserService.FullName</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: #64748b; font-size: 10px;">Online</MudText>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</MudDrawer>
|
||||
</AuthorizeView>
|
||||
|
||||
|
||||
|
||||
<MudMainContent Style="background-color: #f1f5f9;">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pa-3 md-pa-6 lg-pa-8 pt-3 pb-12">
|
||||
@Body
|
||||
</MudContainer>
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
|
||||
|
||||
@code {
|
||||
private bool _drawerOpen = true;
|
||||
|
||||
private void DrawerToggle() => _drawerOpen = !_drawerOpen;
|
||||
private void GoToProfile() => NavigationManager.NavigateTo("/profile");
|
||||
private void GoToTenantSelection() => NavigationManager.NavigateTo("/account/tenant-selection");
|
||||
private void Logout() => NavigationManager.NavigateTo("/account/logout");
|
||||
}
|
||||
|
||||
<style>
|
||||
.custom-menu-popover {
|
||||
min-width: 200px !important;
|
||||
margin-right: 16px !important;
|
||||
}
|
||||
|
||||
.custom-menu-popover .mud-list-item {
|
||||
padding-left: 16px !important;
|
||||
padding-right: 16px !important;
|
||||
}
|
||||
|
||||
.red-text-item {
|
||||
color: var(--mud-palette-error) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.mud-input-control:has(.mud-input-adornment-start) {
|
||||
margin-top: 0 !important;
|
||||
height: 35px !important;
|
||||
}
|
||||
|
||||
.mud-input-control:has(.mud-input-adornment-start) .mud-input-outlined-border {
|
||||
height: 35px !important;
|
||||
}
|
||||
|
||||
.mud-input-control:has(.mud-input-adornment-start) input {
|
||||
padding-top: 0px !important;
|
||||
padding-bottom: 0px !important;
|
||||
height: 30px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.mud-input-control:has(.mud-input-adornment-start) .mud-input-adornment {
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
height: 35px !important;
|
||||
}
|
||||
|
||||
div:has(> .mud-input-control:has(.mud-input-adornment-start)) > .mud-button-root {
|
||||
height: 35px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.mud-input-outlined-border {
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.custom-select-dense .mud-input-control {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.custom-select-dense .mud-input-slot {
|
||||
padding-top: 4px !important;
|
||||
padding-bottom: 4px !important;
|
||||
padding-left: 8px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.mud-drawer.v6-drawer {
|
||||
overflow: visible !important;
|
||||
z-index: 1350 !important;
|
||||
background-color: #f8fafc !important;
|
||||
border-right: 1px solid var(--mud-palette-divider) !important;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.v6-drawer .mud-drawer-content {
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.v6-toggle-btn {
|
||||
position: absolute !important;
|
||||
right: -16px !important;
|
||||
top: 18px !important;
|
||||
z-index: 1400 !important;
|
||||
background-color: white !important;
|
||||
border: 1px solid var(--mud-palette-divider) !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
min-width: 32px !important;
|
||||
border-radius: 50% !important;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1) !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.v6-nav .mud-nav-link,
|
||||
.v6-nav .mud-nav-group {
|
||||
max-width: 100% !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.v6-drawer.mud-drawer--mini .v6-nav .mud-nav-link,
|
||||
.v6-drawer.mud-drawer--mini .v6-nav .mud-nav-group-button {
|
||||
padding-left: 0px !important;
|
||||
padding-right: 0px !important;
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
width: 80px !important;
|
||||
}
|
||||
|
||||
.v6-drawer.mud-drawer--mini .v6-nav .mud-nav-link-icon,
|
||||
.v6-drawer.mud-drawer--mini .v6-nav .mud-nav-group-icon {
|
||||
margin-right: 0px !important;
|
||||
margin-inline-start: 0px !important;
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.v6-nav .mud-nav-link {
|
||||
border-radius: 12px;
|
||||
margin: 4px 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.v6-nav .mud-nav-link.active {
|
||||
background-color: white !important;
|
||||
color: var(--mud-palette-primary) !important;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.v6-footer {
|
||||
margin-top: auto;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--mud-palette-divider);
|
||||
background-color: #f8fafc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mud-badge-dot {
|
||||
border: 2px solid white !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@page "/not-found"
|
||||
@using Microsoft.AspNetCore.Mvc
|
||||
@layout MainLayout
|
||||
@attribute [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Medium" Class="d-flex align-center justify-center" Style="height: calc(100vh - 64px);">
|
||||
|
||||
<div class="text-center">
|
||||
|
||||
<MudIcon Icon="@Icons.Material.Filled.ErrorOutline"
|
||||
Size="Size.Large"
|
||||
Color="Color.Error"
|
||||
Class="mb-6" Style="font-size: 120px;" />
|
||||
|
||||
<MudText Typo="Typo.h3" Class="mb-4">Page Not Found (404)</MudText>
|
||||
|
||||
<MudText Typo="Typo.body1" Class="mb-6 text-muted">
|
||||
Sorry, the page you are looking for is unavailable or has been moved.
|
||||
<br />
|
||||
Please double-check the URL or return to the home page.
|
||||
</MudText>
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
Href="/"
|
||||
StartIcon="@Icons.Material.Filled.Home">
|
||||
Back to Home
|
||||
</MudButton>
|
||||
|
||||
</div>
|
||||
|
||||
</MudContainer>
|
||||
@@ -0,0 +1,474 @@
|
||||
@page "/"
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HRM - Human Resource Management</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.0/dist/cdn.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;450;500;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#EEECFA',
|
||||
100: '#D5D0F5',
|
||||
200: '#BDB8F0',
|
||||
300: '#A9A0F2',
|
||||
400: '#8B82F0',
|
||||
500: '#7B6FE8',
|
||||
600: '#594AE2',
|
||||
700: '#4235B8',
|
||||
800: '#3228A0',
|
||||
900: '#241C80',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body { font-family: "Poppins", sans-serif; background: #ffffff; color: #1e293b; }
|
||||
|
||||
/* === Landing Page Styles (from landing3.css) === */
|
||||
.nav-link { position: relative; color: rgba(255,255,255,0.85); font-size: 0.875rem; font-weight: 500; transition: all 0.2s; cursor: pointer; text-decoration: none; }
|
||||
.nav-link:hover { color: #ffffff; }
|
||||
.nav-link.active { color: #ffffff; font-weight: 600; }
|
||||
.nav-link.active::after { content: ''; position: absolute; bottom: -2px; left: 0; width: 100%; height: 2px; background: rgba(255,255,255,0.6); border-radius: 1px; }
|
||||
|
||||
.btn-primary { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.75rem; background: #ffffff; color: #594AE2; font-size: 0.875rem; font-weight: 600; border-radius: 0.5rem; transition: all 0.2s; cursor: pointer; border: none; }
|
||||
.btn-primary:hover { background: #EEECFA; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
|
||||
|
||||
.btn-outline { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.75rem; background: transparent; color: #ffffff; font-size: 0.875rem; font-weight: 600; border-radius: 0.5rem; border: 1.5px solid rgba(255,255,255,0.3); transition: all 0.2s; cursor: pointer; }
|
||||
.btn-outline:hover { border-color: rgba(255,255,255,0.6); background: rgba(255,255,255,0.1); }
|
||||
|
||||
.tech-card { padding: 1.5rem; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 0.75rem; transition: all 0.25s; cursor: default; }
|
||||
.tech-card:hover { border-color: #BDB8F0; box-shadow: 0 4px 16px rgba(89,74,226,0.06); transform: translateY(-2px); }
|
||||
.tech-card .ico { width: 2.5rem; height: 2.5rem; display: flex; align-items: center; justify-content: center; border-radius: 0.5rem; background: #EEECFA; color: #7B6FE8; margin-bottom: 0.75rem; }
|
||||
.tech-card .ico svg { width: 1.25rem; height: 1.25rem; }
|
||||
|
||||
.feature-icon { width: 3rem; height: 3rem; display: flex; align-items: center; justify-content: center; border-radius: 0.75rem; background: #EEECFA; color: #7B6FE8; flex-shrink: 0; }
|
||||
.feature-icon svg { width: 1.5rem; height: 1.5rem; }
|
||||
|
||||
.footer-link { color: #94a3b8; font-size: 0.8125rem; transition: color 0.2s; cursor: pointer; text-decoration: none; }
|
||||
.footer-link:hover { color: #ffffff; }
|
||||
|
||||
.hero-badge { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.375rem 1rem; background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.2); border-radius: 9999px; font-size: 0.8125rem; color: rgba(255,255,255,0.9); font-weight: 500; }
|
||||
|
||||
#fab-top { position: fixed; bottom: 2rem; right: 2rem; z-index: 100; width: 3rem; height: 3rem; display: flex; align-items: center; justify-content: center; background: #594AE2; color: #fff; border: none; border-radius: 9999px; cursor: pointer; box-shadow: 0 4px 12px rgba(89,74,226,0.3); transition: all 0.25s; opacity: 0; visibility: hidden; transform: translateY(10px); }
|
||||
#fab-top.show { opacity: 1; visibility: visible; transform: translateY(0); }
|
||||
#fab-top:hover { background: #4235B8; transform: translateY(-2px); box-shadow: 0 6px 16px rgba(89,74,226,0.35); }
|
||||
#fab-top svg { width: 1.25rem; height: 1.25rem; }
|
||||
|
||||
.demo-box { background: #ffffff; border: 1px solid #e2e8f0; border-radius: 0.75rem; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
|
||||
.demo-row { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem; background: #f9fafb; border-radius: 0.5rem; }
|
||||
|
||||
/* === Auth/General Styles (from auth3.css) === */
|
||||
.input-field { width: 100%; padding: 0.75rem 1rem; border: 1.5px solid #e2e8f0; border-radius: 0.5rem; font-size: 0.875rem; color: #1e293b; background: #fff; transition: border-color 0.2s; outline: none; font-family: "Poppins", sans-serif; }
|
||||
.input-field:focus { border-color: #594AE2; box-shadow: 0 0 0 3px rgba(89,74,226,0.1); }
|
||||
.input-field::placeholder { color: #94a3b8; }
|
||||
.input-field.error { border-color: #ef4444; }
|
||||
.input-field.error:focus { box-shadow: 0 0 0 3px rgba(239,68,68,0.1); }
|
||||
|
||||
.form-label { display: block; font-size: 0.8125rem; font-weight: 600; color: #475569; margin-bottom: 0.375rem; }
|
||||
|
||||
.divider { display: flex; align-items: center; gap: 1rem; margin: 1.5rem 0; }
|
||||
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: #e2e8f0; }
|
||||
.divider span { font-size: 0.75rem; color: #94a3b8; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
.auth-link { color: #594AE2; font-size: 0.875rem; font-weight: 600; text-decoration: none; cursor: pointer; transition: color 0.2s; }
|
||||
.auth-link:hover { color: #4235B8; text-decoration: underline; }
|
||||
|
||||
.text-link { color: #64748b; font-size: 0.8125rem; text-decoration: none; cursor: pointer; transition: color 0.2s; }
|
||||
.text-link:hover { color: #594AE2; text-decoration: underline; }
|
||||
|
||||
.checkbox-custom { width: 1rem; height: 1rem; border: 1.5px solid #cbd5e1; border-radius: 0.25rem; cursor: pointer; accent-color: #594AE2; }
|
||||
|
||||
.bg-card-glass { background: rgba(255,255,255,0.1); backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.15); }
|
||||
.bg-card-glass:hover { border-color: rgba(255,255,255,0.25); }
|
||||
|
||||
.check-icon { background: rgba(255,255,255,0.15); }
|
||||
|
||||
.tenant-card { cursor: pointer; transition: all 0.2s; border: 1px solid #e2e8f0; border-radius: 0.5rem; }
|
||||
.tenant-card:hover { border-color: #594AE2; box-shadow: 0 4px 12px rgba(89,74,226,0.1); transform: translateY(-2px); }
|
||||
.tenant-card.selected { border-color: #594AE2; background: #EEECFA; }
|
||||
|
||||
.fade-in { animation: fadeIn 0.3s ease-out; }
|
||||
@@keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
@@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner { animation: spin 1s linear infinite; }
|
||||
|
||||
.btn-primary-inverse { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; padding: 0.75rem 1.75rem; background: #ffffff; color: #594AE2; font-size: 0.875rem; font-weight: 600; border-radius: 0.5rem; transition: all 0.2s; cursor: pointer; border: none; text-decoration: none; }
|
||||
.btn-primary-inverse:hover { background: #f8fafc; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
|
||||
.btn-primary:disabled { opacity: 0.7; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
/* === Overrides for brand gradient background pages === */
|
||||
.bg-gradient-to-br .form-label {
|
||||
display: block; font-size: 0.8125rem; font-weight: 600; color: rgba(255,255,255,0.9); margin-bottom: 0.375rem;
|
||||
}
|
||||
.bg-gradient-to-br .auth-link {
|
||||
color: #A9A0F2; font-size: 0.875rem; font-weight: 600; text-decoration: none; cursor: pointer; transition: color 0.2s;
|
||||
}
|
||||
.bg-gradient-to-br .auth-link:hover {
|
||||
color: #ffffff; text-decoration: underline;
|
||||
}
|
||||
.bg-gradient-to-br .text-link {
|
||||
color: rgba(255,255,255,0.6); font-size: 0.8125rem; text-decoration: none; cursor: pointer; transition: color 0.2s;
|
||||
}
|
||||
.bg-gradient-to-br .text-link:hover {
|
||||
color: #ffffff; text-decoration: underline;
|
||||
}
|
||||
.bg-gradient-to-br .btn-primary {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; padding: 0.75rem 1.75rem; background: #ffffff; color: #594AE2; font-size: 0.875rem; font-weight: 600; border-radius: 0.5rem; transition: all 0.2s; cursor: pointer; border: none; width: 100%;
|
||||
}
|
||||
.bg-gradient-to-br .btn-primary:hover {
|
||||
background: #f8fafc; transform: translateY(-1px); box-shadow: 0 4px 14px rgba(0,0,0,0.15);
|
||||
}
|
||||
.bg-gradient-to-br .btn-primary:disabled {
|
||||
opacity: 0.7; cursor: not-allowed; transform: none; box-shadow: none;
|
||||
}
|
||||
.bg-gradient-to-br .btn-outline {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; padding: 0.625rem 1rem; background: transparent; color: #ffffff; font-size: 0.875rem; font-weight: 500; border-radius: 0.5rem; border: 1.5px solid rgba(255,255,255,0.3); transition: all 0.2s; cursor: pointer; width: 100%;
|
||||
}
|
||||
.bg-gradient-to-br .btn-outline:hover {
|
||||
border-color: rgba(255,255,255,0.6); background: rgba(255,255,255,0.1);
|
||||
}
|
||||
.bg-gradient-to-br .input-field {
|
||||
width: 100%; padding: 0.75rem 1rem; border: 1.5px solid rgba(255,255,255,0.2); border-radius: 0.5rem; font-size: 0.875rem; color: #ffffff; background: rgba(255,255,255,0.1); transition: border-color 0.2s; outline: none; backdrop-filter: blur(4px);
|
||||
}
|
||||
.bg-gradient-to-br .input-field:focus {
|
||||
border-color: rgba(255,255,255,0.5); box-shadow: 0 0 0 3px rgba(255,255,255,0.1);
|
||||
}
|
||||
.bg-gradient-to-br .input-field::placeholder {
|
||||
color: rgba(255,255,255,0.5);
|
||||
}
|
||||
.bg-gradient-to-br .input-field.error {
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
.bg-gradient-to-br .input-field.error:focus {
|
||||
box-shadow: 0 0 0 3px rgba(239,68,68,0.2);
|
||||
}
|
||||
.bg-gradient-to-br .checkbox-custom {
|
||||
width: 1rem; height: 1rem; border: 1.5px solid rgba(255,255,255,0.3); border-radius: 0.25rem; cursor: pointer; accent-color: #A9A0F2;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body x-data="{ atTop: true, activeSection: 'home' }" x-init="initScrollSpy(); window.addEventListener('scroll', () => { atTop = window.scrollY < 300; })">
|
||||
|
||||
<!-- ===== HRM Landing Page ===== -->
|
||||
|
||||
<!-- NAV -->
|
||||
<nav class="sticky top-0 z-50 bg-brand-600" x-data="{ mobileOpen: false }">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center gap-10">
|
||||
<a class="flex items-center gap-2 text-white font-bold text-lg no-underline" href="#">
|
||||
<svg class="w-7 h-7 text-white/80" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" /></svg>
|
||||
<span>HRM</span>
|
||||
</a>
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
<a class="nav-link active" href="#home">Overview</a>
|
||||
<a class="nav-link" href="#tech">Features</a>
|
||||
<a class="nav-link" href="#arch">Why HRM?</a>
|
||||
<a class="nav-link" href="#frontend">Tech Stack</a>
|
||||
<a class="nav-link" href="#cta">Demo</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden md:flex items-center gap-3">
|
||||
<a class="nav-link" href="/account/tenant-selection">Sign In</a>
|
||||
<a class="btn-primary" href="https://indotalent.com" target="_blank">Get Source Code</a>
|
||||
</div>
|
||||
<button class="md:hidden p-2 text-white/80 hover:text-white" @@click="mobileOpen = !mobileOpen">
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="mobileOpen" x-cloak class="md:hidden pb-4 space-y-2">
|
||||
<a class="block px-3 py-2 text-sm text-white bg-brand-500 rounded-md" href="#home">Overview</a>
|
||||
<a class="block px-3 py-2 text-sm text-white/80 hover:text-white hover:bg-brand-500 rounded-md" href="#tech">Features</a>
|
||||
<a class="block px-3 py-2 text-sm text-white/80 hover:text-white hover:bg-brand-500 rounded-md" href="#arch">Why HRM?</a>
|
||||
<a class="block px-3 py-2 text-sm text-white/80 hover:text-white hover:bg-brand-500 rounded-md" href="#frontend">Tech Stack</a>
|
||||
<a class="block px-3 py-2 text-sm text-white/80 hover:text-white hover:bg-brand-500 rounded-md" href="#cta">Demo</a>
|
||||
<a class="block px-3 py-2 text-sm text-white/80 hover:text-white hover:bg-brand-500 rounded-md" href="/account/tenant-selection">Sign In</a>
|
||||
<a class="btn-primary w-full justify-center mt-2" href="https://indotalent.com" target="_blank">Get Source Code</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
<section id="home" class="relative bg-brand-600 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-brand-800 via-brand-600 to-brand-500 pointer-events-none"></div>
|
||||
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-24 md:pt-28 md:pb-32">
|
||||
<div class="text-center max-w-4xl mx-auto">
|
||||
<div class="hero-badge mx-auto mb-6">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
Blazor HRM — SaaS · Multitenant
|
||||
</div>
|
||||
<h1 class="text-4xl sm:text-5xl md:text-6xl font-extrabold text-white leading-tight tracking-tight">
|
||||
The Next-Gen<br/>
|
||||
<span class="text-brand-300">People Engine</span>
|
||||
</h1>
|
||||
<p class="mt-6 text-lg md:text-xl text-brand-100 max-w-2xl mx-auto leading-relaxed">
|
||||
Built for scale, engineered for excellence. A production-ready Blazor Server HRM source code
|
||||
that works end-to-end — no more burning AI tokens building from scratch.
|
||||
</p>
|
||||
<div class="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<a class="btn-primary text-base px-8 py-3.5" href="/account/tenant-selection">
|
||||
Sign In
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
|
||||
</a>
|
||||
<a class="btn-outline text-base px-8 py-3.5" href="https://indotalent.com" target="_blank">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/><path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Get Source Code
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-16 flex flex-wrap items-center justify-center gap-8 text-sm text-brand-300">
|
||||
<span class="font-medium text-brand-100">Powered by</span>
|
||||
<span class="flex items-center gap-1.5"><svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" 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> .NET 10 Ready</span>
|
||||
<span class="flex items-center gap-1.5"><svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"/></svg> SaaS Multitenant</span>
|
||||
<span class="flex items-center gap-1.5"><svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg> Production Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- BUSINESS FEATURES -->
|
||||
<section id="tech" class="py-20 md:py-28">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-600 text-xs font-semibold rounded-full border border-brand-200">COMPREHENSIVE HR PLATFORM</span>
|
||||
<h2 class="mt-4 text-3xl sm:text-4xl font-bold text-gray-900">Everything HR, Ready to Deploy</h2>
|
||||
<p class="mt-4 text-gray-500 max-w-2xl mx-auto">From employee profiles to payroll processing — a complete HR ecosystem that covers your entire people operations.</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<div class="tech-card"><div class="ico"><svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"/></svg></div><div class="font-semibold text-gray-900 text-sm">Profile</div><div class="text-xs text-gray-400 mt-0.5">Employee</div></div>
|
||||
<div class="tech-card"><div class="ico"><svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"/></svg></div><div class="font-semibold text-gray-900 text-sm">Leave</div><div class="text-xs text-gray-400 mt-0.5">Management</div></div>
|
||||
<div class="tech-card"><div class="ico"><svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605"/></svg></div><div class="font-semibold text-gray-900 text-sm">Performance</div><div class="text-xs text-gray-400 mt-0.5">Evaluation</div></div>
|
||||
<div class="tech-card"><div class="ico"><svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21v-8.25M15.75 21v-8.25M8.25 21v-8.25M3 9l9-6 9 6m-1.5 12V10.332A48.36 48.36 0 0012 9.75c-2.551 0-5.056.2-7.5.582V21M3 21h18M12 6.75h.008v.008H12V6.75z"/></svg></div><div class="font-semibold text-gray-900 text-sm">Organization</div><div class="text-xs text-gray-400 mt-0.5">Structure</div></div>
|
||||
<div class="tech-card"><div class="ico"><svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></div><div class="font-semibold text-gray-900 text-sm">Payroll</div><div class="text-xs text-gray-400 mt-0.5">Process</div></div>
|
||||
<div class="tech-card"><div class="ico"><svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg></div><div class="font-semibold text-gray-900 text-sm">System Logs</div><div class="text-xs text-gray-400 mt-0.5">Audit Trail</div></div>
|
||||
<div class="tech-card"><div class="ico"><svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg></div><div class="font-semibold text-gray-900 text-sm">App Settings</div><div class="text-xs text-gray-400 mt-0.5">Configuration</div></div>
|
||||
<div class="tech-card"><div class="ico"><svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg></div><div class="font-semibold text-gray-900 text-sm">System Settings</div><div class="text-xs text-gray-400 mt-0.5">Inspection Mode</div></div>
|
||||
<div class="tech-card"><div class="ico"><svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/></svg></div><div class="font-semibold text-gray-900 text-sm">MudBlazor 9</div><div class="text-xs text-gray-400 mt-0.5">UI Library</div></div>
|
||||
<div class="tech-card"><div class="ico"><svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/></svg></div><div class="font-semibold text-gray-900 text-sm">VSA</div><div class="text-xs text-gray-400 mt-0.5">Architecture</div></div>
|
||||
<div class="tech-card"><div class="ico"><svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zm0 9.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zm0 9.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"/></svg></div><div class="font-semibold text-gray-900 text-sm">Monolithic</div><div class="text-xs text-gray-400 mt-0.5">Single Project</div></div>
|
||||
<div class="tech-card"><div class="ico"><svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"/></svg></div><div class="font-semibold text-gray-900 text-sm">LINQ</div><div class="text-xs text-gray-400 mt-0.5">Query Language</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- WHY HRM / PAIN POINTS SECTION -->
|
||||
<section id="arch" class="py-20 md:py-28 bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-600 text-xs font-semibold rounded-full border border-brand-200">WHY HRM?</span>
|
||||
<h2 class="mt-4 text-3xl sm:text-4xl font-bold text-gray-900">Stop Prompting. Start Shipping.</h2>
|
||||
<p class="mt-4 text-gray-500 max-w-2xl mx-auto">AI can generate code snippets, but building a fully functional, end-to-end application is a different challenge entirely. HRM solves the real problems behind enterprise software delivery.</p>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div class="p-6 bg-white rounded-xl border border-gray-100 hover:shadow-sm transition-shadow">
|
||||
<div class="feature-icon mb-4">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Guaranteed to Run & Beautiful</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 leading-relaxed">No AI hallucinations, no broken compiles. HRM runs on day one — and looks stunning doing it. A real-world showcase of Blazor + MudBlazor 9 proving .NET can deliver gorgeous enterprise UIs that actually work.</p>
|
||||
</div>
|
||||
<div class="p-6 bg-white rounded-xl border border-gray-100 hover:shadow-sm transition-shadow">
|
||||
<div class="feature-icon mb-4">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Save Weeks of Development</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 leading-relaxed">Even with AI assistance, building a complete HRM from scratch takes weeks of prompting, fixing, and refactoring. Get the finished product now and focus on what makes your business unique.</p>
|
||||
</div>
|
||||
<div class="p-6 bg-white rounded-xl border border-gray-100 hover:shadow-sm transition-shadow">
|
||||
<div class="feature-icon mb-4">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 7.5l3-3 3 3"/></svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Vertical Slice Architecture</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 leading-relaxed">Feature-first organization. Each business capability is an independent slice — commands, queries, handlers, and validation — making the codebase intuitive to navigate and extend.</p>
|
||||
</div>
|
||||
<div class="p-6 bg-white rounded-xl border border-gray-100 hover:shadow-sm transition-shadow">
|
||||
<div class="feature-icon mb-4">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"/></svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Single Project Monolith</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 leading-relaxed">No microservice complexity. No multi-project juggling. One solution, one project — open in Visual Studio 2026, hit F5, and you're running. Perfect for teams that value simplicity.</p>
|
||||
</div>
|
||||
<div class="p-6 bg-white rounded-xl border border-gray-100 hover:shadow-sm transition-shadow">
|
||||
<div class="feature-icon mb-4">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/></svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Clean, Modern C# Codebase</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 leading-relaxed">CQRS with MediatR, LINQ, and best-practice patterns throughout. Learn from production code that follows Microsoft's latest conventions for .NET 10 and ASP.NET Core.</p>
|
||||
</div>
|
||||
<div class="p-6 bg-white rounded-xl border border-gray-100 hover:shadow-sm transition-shadow">
|
||||
<div class="feature-icon mb-4">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z"/></svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Multi-Tenant Ready</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 leading-relaxed">Serve multiple organizations from a single deployment. Built-in tenant isolation ensures complete data separation — each tenant sees only their own employees, leaves, and payroll data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TECH STACK SECTION -->
|
||||
<section id="frontend" class="py-20 md:py-28">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-600 text-xs font-semibold rounded-full border border-brand-200">TECH STACK</span>
|
||||
<h2 class="mt-4 text-3xl sm:text-4xl font-bold text-gray-900">Powered by .NET 10 & Blazor</h2>
|
||||
<p class="mt-4 text-gray-500 leading-relaxed">Built on Microsoft's latest stack. Blazor Server for real-time UI, MudBlazor for polished components, and MS SQL for rock-solid data persistence — all in one cohesive solution.</p>
|
||||
<div class="mt-8 space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-5 h-5 rounded bg-brand-200 flex items-center justify-center flex-shrink-0 mt-0.5"><svg class="w-3 h-3 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg></div>
|
||||
<div><span class="font-semibold text-gray-900 text-sm">ASP.NET Core 10 & Blazor Server</span><p class="text-xs text-gray-400 mt-0.5">Real-time, component-based web UI with C#</p></div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-5 h-5 rounded bg-brand-200 flex items-center justify-center flex-shrink-0 mt-0.5"><svg class="w-3 h-3 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg></div>
|
||||
<div><span class="font-semibold text-gray-900 text-sm">MudBlazor 9</span><p class="text-xs text-gray-400 mt-0.5">Material Design component library for Blazor</p></div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-5 h-5 rounded bg-brand-200 flex items-center justify-center flex-shrink-0 mt-0.5"><svg class="w-3 h-3 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg></div>
|
||||
<div><span class="font-semibold text-gray-900 text-sm">MediatR + CQRS + VSA</span><p class="text-xs text-gray-400 mt-0.5">Clean command/query separation with feature slices</p></div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-5 h-5 rounded bg-brand-200 flex items-center justify-center flex-shrink-0 mt-0.5"><svg class="w-3 h-3 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg></div>
|
||||
<div><span class="font-semibold text-gray-900 text-sm">MS SQL + EF Core</span><p class="text-xs text-gray-400 mt-0.5">Reliable data layer with full migration support</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-5 bg-gray-50 rounded-xl border border-gray-100"><div class="text-2xl font-bold text-brand-600">.NET 10</div><div class="text-xs text-gray-400 mt-1">Runtime</div></div>
|
||||
<div class="p-5 bg-gray-50 rounded-xl border border-gray-100"><div class="text-2xl font-bold text-brand-600">Blazor</div><div class="text-xs text-gray-400 mt-1">Web UI</div></div>
|
||||
<div class="p-5 bg-gray-50 rounded-xl border border-gray-100"><div class="text-2xl font-bold text-brand-600">MudBlazor</div><div class="text-xs text-gray-400 mt-1">Components</div></div>
|
||||
<div class="p-5 bg-gray-50 rounded-xl border border-gray-100"><div class="text-2xl font-bold text-brand-600">MS SQL</div><div class="text-xs text-gray-400 mt-1">Database</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DEMO SECTION -->
|
||||
<section id="cta" class="py-20 md:py-28 bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-600 text-xs font-semibold rounded-full border border-brand-200">SEE IT IN ACTION</span>
|
||||
<h2 class="mt-4 text-3xl sm:text-4xl font-bold text-gray-900">Try the Live Demo</h2>
|
||||
<p class="mt-4 text-gray-500 max-w-2xl mx-auto">Don't take our word for it. Explore every feature in the live demo — no installation required.</p>
|
||||
</div>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="demo-box p-8">
|
||||
<div class="text-center mb-6">
|
||||
<div class="feature-icon mx-auto mb-4">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900">Demo Credentials</h3>
|
||||
<p class="mt-2 text-sm text-gray-500">Use these credentials to sign in and explore the full application:</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="demo-row">
|
||||
<span class="text-sm text-gray-500">URL</span>
|
||||
<a href="https://blazor-saas-hrm.csharpasp.net/" target="_blank" class="text-sm font-medium text-brand-600 hover:text-brand-700">blazor-saas-hrm.csharpasp.net</a>
|
||||
</div>
|
||||
<div class="demo-row">
|
||||
<span class="text-sm text-gray-500">Email</span>
|
||||
<span class="text-sm font-medium text-gray-900">admin@root.com</span>
|
||||
</div>
|
||||
<div class="demo-row">
|
||||
<span class="text-sm text-gray-500">Password</span>
|
||||
<span class="text-sm font-medium text-gray-900">123456</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-center">
|
||||
<a href="https://blazor-saas-hrm.csharpasp.net/" target="_blank" class="inline-flex items-center gap-2 px-8 py-3.5 bg-brand-600 text-white font-semibold rounded-lg hover:bg-brand-700 transition text-sm no-underline">
|
||||
Open Live Demo
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="py-20 md:py-28 bg-gray-900 text-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold">Stop Burning AI Tokens. Start Building.</h2>
|
||||
<p class="mt-4 text-gray-400 max-w-xl mx-auto">AI can scaffold a page. It can't guarantee a working payroll engine. HRM gives you the real deal — a complete, battle-tested HR platform with full source code.</p>
|
||||
<div class="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<a class="inline-flex items-center gap-2 px-6 py-3 bg-brand-600 text-white font-semibold rounded-lg hover:bg-brand-700 transition text-sm no-underline" href="https://indotalent.com" target="_blank">Get the Source Code</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="bg-gray-900 border-t border-gray-800">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="grid sm:grid-cols-2 md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-white font-bold text-lg mb-4">
|
||||
<svg class="w-6 h-6 text-brand-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" /></svg>
|
||||
HRM
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 leading-relaxed">The ultimate bridge between your business and your customers. A complete HRM source code solution built on .NET 10 & Blazor.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-brand-400 uppercase tracking-wider mb-4">Business Features</h4>
|
||||
<div class="space-y-2"><a class="footer-link block">Employee Profiles</a><a class="footer-link block">Leave Management</a><a class="footer-link block">Performance Reviews</a><a class="footer-link block">Payroll Processing</a></div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-brand-400 uppercase tracking-wider mb-4">Technical</h4>
|
||||
<div class="space-y-2"><a class="footer-link block">.NET 10 + Blazor</a><a class="footer-link block">MudBlazor 9</a><a class="footer-link block">MS SQL + EF Core</a><a class="footer-link block">VSA + CQRS</a></div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-brand-400 uppercase tracking-wider mb-4">Resources</h4>
|
||||
<div class="space-y-2"><a class="footer-link block" href="https://blazor-saas-hrm.csharpasp.net/" target="_blank">Live Demo</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-10 pt-8 border-t border-gray-800 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p class="text-xs text-gray-600">© 2026 HRM. All rights reserved.</p>
|
||||
<div class="flex gap-4">
|
||||
<a class="text-gray-500 hover:text-gray-300 transition text-xs cursor-pointer">Privacy</a>
|
||||
<a class="text-gray-500 hover:text-gray-300 transition text-xs cursor-pointer">Terms</a>
|
||||
<a class="text-gray-500 hover:text-gray-300 transition text-xs cursor-pointer">Cookies</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- FAB Back to Top -->
|
||||
<button id="fab-top" :class="atTop ? '' : 'show'" @@click="window.scrollTo({top:0,behavior:'smooth'})" aria-label="Back to top">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/></svg>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
function initScrollSpy() {
|
||||
const observer = new IntersectionObserver(function(entries) {
|
||||
entries.forEach(function(e) {
|
||||
if (e.isIntersecting) {
|
||||
document.querySelectorAll('.nav-link').forEach(function(l) { l.classList.remove('active'); });
|
||||
var el = document.querySelector('.nav-link[href="#'+e.target.id+'"]');
|
||||
if (el) el.classList.add('active');
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '-80px 0px -50% 0px' });
|
||||
document.querySelectorAll('section[id]').forEach(function(s) { observer.observe(s); });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,258 @@
|
||||
|
||||
|
||||
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||
<div class="components-reconnect-container">
|
||||
<div class="components-rejoining-animation" aria-hidden="true">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<p class="components-reconnect-first-attempt-visible">
|
||||
Rejoining the server...
|
||||
</p>
|
||||
<p class="components-reconnect-repeated-attempt-visible">
|
||||
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||
</p>
|
||||
<p class="components-reconnect-failed-visible">
|
||||
Failed to rejoin.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||
Retry
|
||||
</button>
|
||||
<p class="components-pause-visible">
|
||||
The session has been paused by the server.
|
||||
</p>
|
||||
<p class="components-resume-failed-visible">
|
||||
Failed to resume the session.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.components-reconnect-first-attempt-visible,
|
||||
.components-reconnect-repeated-attempt-visible,
|
||||
.components-reconnect-failed-visible,
|
||||
.components-pause-visible,
|
||||
.components-resume-failed-visible,
|
||||
.components-rejoining-animation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
|
||||
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-failed,
|
||||
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
#components-reconnect-modal {
|
||||
background-color: white;
|
||||
width: 20rem;
|
||||
margin: 20vh auto;
|
||||
padding: 2rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
|
||||
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
|
||||
|
||||
&[open] {
|
||||
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
}
|
||||
|
||||
#components-reconnect-modal::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@keyframes components-reconnect-modal-slideUp {
|
||||
0% {
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@keyframes components-reconnect-modal-fadeInOpacity {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@keyframes components-reconnect-modal-fadeOutOpacity {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.components-reconnect-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#components-reconnect-modal p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button {
|
||||
border: 0;
|
||||
background-color: #6b9ed2;
|
||||
color: white;
|
||||
padding: 4px 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:hover {
|
||||
background-color: #3b6ea2;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:active {
|
||||
background-color: #6b9ed2;
|
||||
}
|
||||
|
||||
.components-rejoining-animation {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div {
|
||||
position: absolute;
|
||||
border: 3px solid #0087ff;
|
||||
opacity: 1;
|
||||
border-radius: 50%;
|
||||
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div:nth-child(2) {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
@@keyframes components-rejoining-animation {
|
||||
0% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
4.9% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
5% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Set up event handlers
|
||||
const reconnectModal = document.getElementById("components-reconnect-modal");
|
||||
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
|
||||
|
||||
const retryButton = document.getElementById("components-reconnect-button");
|
||||
retryButton.addEventListener("click", retry);
|
||||
|
||||
const resumeButton = document.getElementById("components-resume-button");
|
||||
resumeButton.addEventListener("click", resume);
|
||||
|
||||
function handleReconnectStateChanged(event) {
|
||||
if (event.detail.state === "show") {
|
||||
reconnectModal.showModal();
|
||||
} else if (event.detail.state === "hide") {
|
||||
reconnectModal.close();
|
||||
} else if (event.detail.state === "failed") {
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
} else if (event.detail.state === "rejected") {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
|
||||
try {
|
||||
// Reconnect will asynchronously return:
|
||||
// - true to mean success
|
||||
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
|
||||
// - exception to mean we didn't reach the server (this can be sync or async)
|
||||
const successful = await Blazor.reconnect();
|
||||
if (!successful) {
|
||||
// We have been able to reach the server, but the circuit is no longer available.
|
||||
// We'll reload the page so the user can continue using the app as quickly as possible.
|
||||
const resumeSuccessful = await Blazor.resumeCircuit();
|
||||
if (!resumeSuccessful) {
|
||||
location.reload();
|
||||
} else {
|
||||
reconnectModal.close();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// We got an exception, server is currently unavailable
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
}
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
try {
|
||||
const successful = await Blazor.resumeCircuit();
|
||||
if (!successful) {
|
||||
location.reload();
|
||||
}
|
||||
} catch {
|
||||
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function retryWhenDocumentBecomesVisible() {
|
||||
if (document.visibilityState === "visible") {
|
||||
await retry();
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
@using Indotalent.ConfigBackEnd.Interfaces
|
||||
@using Indotalent.Features.Account.AccessDenied
|
||||
@using Indotalent.Features.Root.NotFound
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
|
||||
|
||||
<CascadingAuthenticationState>
|
||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(NotFound.NotFoundPage)">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Root.MainLayout)">
|
||||
<NotAuthorized>
|
||||
<RedirectToAccessDenied />
|
||||
</NotAuthorized>
|
||||
<Authorizing>
|
||||
<div class="d-flex align-center justify-center mt-16">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
|
||||
<MudText Class="ml-3">Memverifikasi identitas...</MudText>
|
||||
</div>
|
||||
</Authorizing>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
</CascadingAuthenticationState>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<div class="pa-4" style="background-color: #f44336; margin: -16px -24px 0 -24px;">
|
||||
<div class="d-flex align-center justify-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Style="color: white;" Size="Size.Large" />
|
||||
<MudText Typo="Typo.h6" Style="color: white; font-weight: 800; margin-left: 12px;">Confirm Delete</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<div class="pa-4 text-center">
|
||||
<MudText Typo="Typo.body1" Class="mb-2">Are you sure you want to delete <b>@ContentText</b>?</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Error" Style="font-weight: 700;">This action cannot be undone.</MudText>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<div class="d-flex justify-center gap-2 w-100 pb-4">
|
||||
<MudButton OnClick="Cancel"
|
||||
Variant="Variant.Outlined"
|
||||
Disabled="_isDeleting"
|
||||
Style="border: 1px solid #e0e0e0; border-radius: 4px; text-transform: none; font-weight: 700;">Cancel</MudButton>
|
||||
<MudButton Color="Color.Error"
|
||||
Variant="Variant.Filled"
|
||||
OnClick="HandleSubmit"
|
||||
Disabled="_isDeleting"
|
||||
Style="border-radius: 4px; text-transform: none; font-weight: 700;">
|
||||
@if (_isDeleting)
|
||||
{
|
||||
<MudProgressCircular Class="ms-n1" Size="Size.Small" Indeterminate="true" />
|
||||
<MudText Class="ms-2">Deleting...</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText>Yes, Delete It</MudText>
|
||||
}
|
||||
</MudButton>
|
||||
</div>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!;
|
||||
[Parameter] public string ContentText { get; set; } = string.Empty;
|
||||
|
||||
private bool _isDeleting = false;
|
||||
|
||||
private async Task HandleSubmit()
|
||||
{
|
||||
_isDeleting = true;
|
||||
StateHasChanged();
|
||||
await Task.Delay(1000);
|
||||
MudDialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
@using Indotalent.Infrastructure.Authentication.Identity
|
||||
@using Microsoft.Extensions.Options
|
||||
@inject IOptions<IdentitySettingsModel> IdentityOptions
|
||||
|
||||
@if (IdentityOptions.Value.SsoFirebase.IsUsed)
|
||||
{
|
||||
<script src="https://www.gstatic.com/firebasejs/9.23.0/firebase-app-compat.js"></script>
|
||||
<script src="https://www.gstatic.com/firebasejs/9.23.0/firebase-auth-compat.js"></script>
|
||||
<script>
|
||||
const firebaseConfig = {
|
||||
apiKey: "@IdentityOptions.Value.SsoFirebase.ApiKey",
|
||||
authDomain: "@IdentityOptions.Value.SsoFirebase.AuthDomain",
|
||||
projectId: "@IdentityOptions.Value.SsoFirebase.ProjectId",
|
||||
storageBucket: "@IdentityOptions.Value.SsoFirebase.StorageBucket",
|
||||
messagingSenderId: "@IdentityOptions.Value.SsoFirebase.MessagingSenderId",
|
||||
appId: "@IdentityOptions.Value.SsoFirebase.AppId"
|
||||
};
|
||||
|
||||
if (typeof firebase !== 'undefined' && !firebase.apps.length) {
|
||||
firebase.initializeApp(firebaseConfig);
|
||||
}
|
||||
|
||||
async function signInWithGoogle() {
|
||||
if (typeof firebase === 'undefined') return null;
|
||||
const provider = new firebase.auth.GoogleAuthProvider();
|
||||
try {
|
||||
const result = await firebase.auth().signInWithPopup(provider);
|
||||
return { email: result.user.email };
|
||||
} catch (error) {
|
||||
console.error("Firebase Auth Error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function firebaseLogout() {
|
||||
if (typeof firebase !== 'undefined') {
|
||||
await firebase.auth().signOut();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using Indotalent
|
||||
@using Indotalent.Features.Root
|
||||
@using MudBlazor
|
||||
|
||||
@inject NavigationManager NavigationManager
|
||||
Reference in New Issue
Block a user