Files
2026-07-21 14:08:10 +07:00

498 lines
18 KiB
C#

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)
};
}
}