initial commit
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
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 EngagementRate { 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;
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
var totalBranches = await _context.Branch.NotDeletedOnly().CountAsync(ct);
|
||||
var totalDepts = await _context.Department.NotDeletedOnly().CountAsync(ct);
|
||||
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);
|
||||
|
||||
// Latest payroll
|
||||
var latestPayroll = await _context.PayrollProcess
|
||||
.NotDeletedOnly()
|
||||
.OrderByDescending(x => x.ToDate)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// --- Performers (materialize first) ---
|
||||
var rawEvaluationData = await _context.Evaluation
|
||||
.Include(x => x.Employee).ThenInclude(e => e!.Department)
|
||||
.Include(x => x.Employee).ThenInclude(e => e!.Grade)
|
||||
.NotDeletedOnly()
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Take(10)
|
||||
.ToListAsync(ct);
|
||||
|
||||
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();
|
||||
|
||||
// --- 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);
|
||||
|
||||
// --- Leaves ---
|
||||
var leaves = await _context.LeaveRequest
|
||||
.NotDeletedOnly()
|
||||
.Where(x => x.Status == "Pending")
|
||||
.Take(5)
|
||||
.Select(x => new LeaveTicketDto
|
||||
{
|
||||
TicketId = x.AutoNumber,
|
||||
Status = x.Status,
|
||||
DueInfo = "Active"
|
||||
}).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 upcomingPromotions = await _context.Promotion
|
||||
.Include(x => x.Employee)
|
||||
.NotDeletedOnly()
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Take(3)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var promoDtos = upcomingPromotions.Select(x => new CareerPathDto
|
||||
{
|
||||
EmployeeName = x.Employee != null ? x.Employee.FirstName : "Staff",
|
||||
ToGrade = x.ToGrade,
|
||||
Date = x.EffectiveDate
|
||||
}).ToList();
|
||||
|
||||
// --- Compensation Stats ---
|
||||
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 (include ALL departments, even with zero employees) ---
|
||||
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>();
|
||||
|
||||
// Latest appraisal
|
||||
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"
|
||||
});
|
||||
}
|
||||
|
||||
// Latest promotion
|
||||
if (upcomingPromotions.Any())
|
||||
{
|
||||
var p = upcomingPromotions.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"
|
||||
});
|
||||
}
|
||||
|
||||
// Latest leave request
|
||||
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"
|
||||
});
|
||||
}
|
||||
|
||||
// Latest transfer
|
||||
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 = 94.8,
|
||||
EngagementRate = 74.2,
|
||||
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)
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user