User
add: language switch
8c88a89
Raw
History Blame Contribute Delete
27 kB
@page "/reports/employees"
@using Microsoft.AspNetCore.Authorization
@using TaskTrackingSystem.Shared
@using TaskTrackingSystem.Shared.Models.User
@using TaskTrackingSystem.Shared.Models.Task
@using TaskTrackingSystem.Shared.Models.Report
@using System.Text
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@attribute [Authorize]
@inject ApiClientService ApiClient
@inject IJSRuntime JS
<PageTitle>@AppLocalization.Text("report.employeeWorkloadTitle", "Employee Workload & Performance")</PageTitle>
<div class="space-y-6">
<!-- Page Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.Text("report.employeeWorkloadHeading", "Employee Workload & Performance")</h2>
<p class="text-slate-500 mt-1">@AppLocalization.Text("report.employeeWorkloadDesc", "Monitor issue workload, execution health, and actual effort.")</p>
</div>
<div class="flex items-center space-x-3">
<div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
<button @onclick="() => SetView(false)" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold @(!isChartView ? "bg-violet-600 text-white" : "text-slate-600 hover:text-slate-900") transition-colors">
<i data-lucide="list" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.listView", "List View")
</button>
<button @onclick="() => SetView(true)" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold @(isChartView ? "bg-violet-600 text-white" : "text-slate-600 hover:text-slate-900") transition-colors">
<i data-lucide="bar-chart-3" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.chartView", "Chart View")
</button>
</div>
<button @onclick="DownloadExcel" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm">
<i data-lucide="download" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportExcel", "Export Excel")
</button>
<button @onclick="DownloadPdf" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm">
<i data-lucide="file-text" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportPdf", "Export PDF")
</button>
</div>
</div>
<!-- Filters -->
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-end gap-4">
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("report.searchEmployee", "Search Employee")</label>
<input @bind="searchInput" type="text" placeholder="@AppLocalization.Text("common.searchByName", "Search by name...")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" />
</div>
<div>
<label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("report.periodFilter", "Period Filter")</label>
<select @bind="filterPeriod" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all">
<option value="all">@AppLocalization.Text("common.all", "All Time")</option>
<option value="daily">Daily (Today)</option>
<option value="monthly">Monthly (This Month)</option>
<option value="yearly">Yearly (This Year)</option>
<option value="custom">Custom Range</option>
</select>
</div>
@if (filterPeriod == "custom")
{
<div>
<label class="block text-xs font-medium text-slate-500 mb-1.5">Start Date</label>
<input @bind="filterStartDate" type="date" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" />
</div>
<div>
<label class="block text-xs font-medium text-slate-500 mb-1.5">End Date</label>
<input @bind="filterEndDate" type="date" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" />
</div>
}
<div>
<label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.status", "Task Status")</label>
<select @bind="filterStatus" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all">
<option value="0">@AppLocalization.Text("common.allTasks", "All Tasks")</option>
<option value="1">@AppLocalization.StatusLabel(AppTaskStatus.Todo)</option>
<option value="2">@AppLocalization.StatusLabel(AppTaskStatus.InProgress)</option>
<option value="3">@AppLocalization.StatusLabel(AppTaskStatus.Done)</option>
<option value="overdue">@AppLocalization.Text("status.overdue", "Overdue")</option>
</select>
</div>
<button @onclick="ApplyAllFilters" class="inline-flex items-center rounded-lg bg-violet-600 px-5 py-2 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm">
<i data-lucide="search" class="h-4 w-4 mr-2"></i> Search
</button>
<button @onclick="ResetAllFilters" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm">
<i data-lucide="rotate-ccw" class="h-4 w-4 mr-2 text-slate-500"></i> Reset
</button>
<div class="flex flex-wrap items-center gap-6 w-full border-t border-slate-100 pt-3 mt-1">
<label class="flex items-center space-x-2 text-sm font-medium text-slate-700 cursor-pointer select-none">
<input type="checkbox" @bind="filterAssignToMe" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" />
<span>@AppLocalization.Text("report.assignedToMe", "Assigned to me")</span>
</label>
<label class="flex items-center space-x-2 text-sm font-medium text-slate-700 cursor-pointer select-none">
<input type="checkbox" @bind="filterAssignToMyTeam" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" />
<span>@AppLocalization.Text("report.assignedToMyTeam", "Assigned to my team")</span>
</label>
</div>
</div>
</div>
<!-- Content Area -->
@if (isLoading)
{
<div class="flex items-center justify-center py-20">
<div class="flex items-center space-x-3 text-slate-500">
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
<span class="text-sm font-medium">@AppLocalization.Text("common.loading", "Loading data...")</span>
</div>
</div>
}
else
{
@if (isChartView)
{
<div class="grid grid-cols-1 gap-5 xl:grid-cols-12">
<div class="xl:col-span-7">
<WorkloadHeatmap Title="@AppLocalization.Text("report.workloadHeatmap", "Workload heatmap")" Subtitle="@AppLocalization.Text("report.workloadHeatmapDesc", "Capacity signals across assigned work, open issues, overdue issues, and hours.")" Rows="EmployeeHeatmapRows" Columns="EmployeeHeatmapColumns" Cells="EmployeeHeatmapCells" Insight="@AppLocalization.Text("report.workloadHeatmapInsight", "Darker cells mean someone may need support or scope adjustment.")" />
</div>
<div class="xl:col-span-5">
<ReportChartCard Title="@AppLocalization.Text("report.capacitySupportList", "Capacity support list")" Subtitle="@AppLocalization.Text("report.capacitySupportDesc", "Highest support signals, phrased as capacity risk.")" Insight="@AppLocalization.Text("report.capacitySupportInsight", "Use this to route help and remove blockers, not to rank people negatively.")">
<TopRiskList Items="EmployeeCapacityRiskItems" EmptyText="@AppLocalization.Text("report.noCapacityRisks", "No capacity risks in the current filter.")" />
</ReportChartCard>
</div>
<div class="xl:col-span-5">
<ReportChartCard Title="@AppLocalization.Text("report.completedVsOpenIssues", "Completed vs open issues")" Subtitle="@AppLocalization.Text("report.teamExecutionBalance", "Team execution balance by issue state.")" Value="@CompletedIssueTotal.ToString()" ValueLabel="@AppLocalization.Text("report.completedTasks", "completed tasks")">
<StackedProgressBar Segments="EmployeeCompletionSegments" AriaLabel="Completed versus open employee workload" />
</ReportChartCard>
</div>
<div class="xl:col-span-4">
<ReportChartCard Title="@AppLocalization.Text("report.overdueSupport", "Overdue support")" Subtitle="@AppLocalization.Text("report.employeesWithOverdueLoad", "Employees with overdue issue load.")" Insight="@AppLocalization.Text("report.overdueSupportInsight", "Treat overdue counts as support requests.")">
<TopRiskList Items="EmployeeOverdueItems" EmptyText="@AppLocalization.Text("report.noOverdueSupport", "No overdue support needs found.")" />
</ReportChartCard>
</div>
<div class="xl:col-span-3">
<TrendSparklineCard Title="@AppLocalization.Text("report.completionPattern", "Completion pattern")" Subtitle="@AppLocalization.Text("report.completionPatternDesc", "Completion rate across visible employees.")" Value="@($"{AverageCompletionRate:0}%")" ValueLabel="@AppLocalization.Text("report.avgRate", "avg rate")" Points="EmployeeCompletionSparkline" Color="#3b82f6" Insight="@AppLocalization.Text("report.completionPatternInsight", "A quick view of whether completion is clustered or balanced.")" />
</div>
</div>
}
else
{
<!-- List View -->
<div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-slate-100 bg-slate-50/60">
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">No.</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Employee</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Assigned Issues</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Open / Overdue</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Actual Hours</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Workload</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Trend</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@if (pagedEmployees.Any())
{
@foreach (var entry in pagedEmployees.Select((emp, index) => (emp, index)))
{
var emp = entry.emp;
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="px-6 py-4 text-sm font-semibold text-slate-500">@((currentPage - 1) * pageSize + entry.index + 1)</td>
<td class="px-6 py-4">
<div class="flex items-center space-x-3">
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-700 font-semibold text-xs">
@(emp.FullName.Length > 0 ? emp.FullName[0].ToString().ToUpper() : "U")
</span>
<div>
<p class="text-sm font-semibold text-slate-900">@emp.FullName</p>
<p class="text-xs text-slate-400">@@@emp.Username</p>
</div>
</div>
</td>
<td class="px-6 py-4 text-sm font-medium text-slate-900">@emp.AssignedIssues</td>
<td class="px-6 py-4 text-sm font-medium"><span class="text-blue-600">@emp.OpenIssues</span> / <span class="text-rose-600">@emp.OverdueIssues</span></td>
<td class="px-6 py-4 text-sm font-semibold text-slate-800">@emp.ActualHours.ToString("N1") hrs</td>
<td class="px-6 py-4"><span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold @GetWorkloadBadge(emp.WorkloadLevel)">@emp.WorkloadLevel</span></td>
<td class="px-6 py-4"><span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold @GetTrendBadge(emp.Trend)">@emp.Trend</span></td>
</tr>
}
}
else
{
<tr>
<td colspan="7" class="px-6 py-12 text-center">
<p class="text-sm text-slate-400">No employee data found matching criteria.</p>
</td>
</tr>
}
</tbody>
</table>
<!-- Pagination -->
<Pagination CurrentPage="currentPage"
TotalPages="TotalPages"
PageSize="pageSize"
TotalCount="TotalCount"
OnPageChanged="HandlePageChanged"
OnPageSizeChanged="HandlePageSizeChanged" />
</div>
}
}
</div>
@code {
private bool isLoading = true;
private bool isChartView = false;
private List<EmployeeProductivityReportDto> allEmployees = new();
private List<EmployeeProductivityReportDto> pagedEmployees = new();
private PagedResult<EmployeeProductivityReportDto>? reportPage;
// Filters
private string filterPeriod = "all";
private DateTime? filterStartDate;
private DateTime? filterEndDate;
private string filterStatus = "0";
private bool filterAssignToMe = false;
private bool filterAssignToMyTeam = false;
private List<string> EmployeeHeatmapRows => allEmployees.Take(6).Select(emp => emp.FullName).ToList();
private List<string> EmployeeHeatmapColumns => new() { "Assigned", "Open", "Overdue", "Hours" };
private int CompletedIssueTotal => allEmployees.Sum(emp => emp.CompletedCount);
private double AverageCompletionRate => allEmployees.Any() ? allEmployees.Average(emp => emp.CompletionRate) : 0;
private List<HeatmapCell> EmployeeHeatmapCells => allEmployees.Take(6)
.SelectMany(emp => new[]
{
new HeatmapCell(emp.FullName, "Assigned", Math.Min(100, emp.AssignedCount * 8), $"{emp.AssignedCount} assigned tasks"),
new HeatmapCell(emp.FullName, "Open", Math.Min(100, emp.OpenIssues * 12), $"{emp.OpenIssues} open issues"),
new HeatmapCell(emp.FullName, "Overdue", Math.Min(100, emp.OverdueIssues * 20), $"{emp.OverdueIssues} overdue issues"),
new HeatmapCell(emp.FullName, "Hours", Math.Min(100, (double)emp.ActualHours * 5), $"{emp.ActualHours:N1} actual hours")
})
.ToList();
private List<TopRiskItem> EmployeeCapacityRiskItems => allEmployees
.Select(emp =>
{
var score = Math.Min(100, (emp.OpenIssues * 14) + (emp.OverdueIssues * 24) + (emp.AssignedCount * 4) + (double)emp.ActualHours);
var tone = score >= 70 ? "rose" : score >= 45 ? "amber" : "blue";
return new TopRiskItem(emp.FullName, $"{emp.WorkloadLevel} · {emp.ActualHours:N1}h tracked", score, "needs support", tone, $"{emp.OpenIssues} open · {emp.OverdueIssues} overdue");
})
.OrderByDescending(item => item.Score)
.Take(5)
.ToList();
private List<StackedSegment> EmployeeCompletionSegments => new()
{
new("Completed tasks", allEmployees.Sum(emp => emp.CompletedCount), "#10b981"),
new("Assigned tasks", allEmployees.Sum(emp => Math.Max(0, emp.AssignedCount - emp.CompletedCount)), "#3b82f6"),
new("Open issues", allEmployees.Sum(emp => emp.OpenIssues), "#f59e0b")
};
private List<TopRiskItem> EmployeeOverdueItems => allEmployees
.Where(emp => emp.OverdueIssues > 0)
.OrderByDescending(emp => emp.OverdueIssues)
.Take(5)
.Select(emp => new TopRiskItem(emp.FullName, $"{emp.OpenIssues} open issues", Math.Min(100, emp.OverdueIssues * 25), $"{emp.OverdueIssues} overdue", "rose", "Capacity risk"))
.ToList();
private List<SparklinePoint> EmployeeCompletionSparkline => allEmployees
.OrderByDescending(emp => emp.AssignedCount)
.Take(8)
.Select(emp => new SparklinePoint(BuildShortName(emp.FullName), emp.CompletionRate))
.DefaultIfEmpty(new SparklinePoint("No data", 0))
.ToList();
// Search and Pagination
private string searchInput = "";
private string searchQuery = "";
private int currentPage = 1;
private int pageSize = 10;
private int TotalPages => reportPage?.TotalPages ?? 0;
private int TotalCount => reportPage?.TotalCount ?? 0;
protected override async Task OnInitializedAsync()
{
await LoadEmployeeReportData();
isLoading = false;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JS.InvokeVoidAsync("initIcons");
}
private void SetView(bool chartView)
{
isChartView = chartView;
}
private async Task ApplyAllFilters()
{
ApplyPeriodPreset();
searchQuery = searchInput;
currentPage = 1;
await LoadEmployeeReportData();
}
private async Task ResetAllFilters()
{
searchInput = "";
searchQuery = "";
filterPeriod = "all";
filterStartDate = null;
filterEndDate = null;
filterStatus = "0";
filterAssignToMe = false;
filterAssignToMyTeam = false;
currentPage = 1;
await LoadEmployeeReportData();
}
private async Task HandlePageChanged(int page)
{
currentPage = page;
await LoadEmployeeReportData();
}
private async Task HandlePageSizeChanged(int size)
{
pageSize = size;
currentPage = 1;
await LoadEmployeeReportData();
}
private void ApplyPeriodPreset()
{
var today = DateTime.Today;
if (filterPeriod == "daily")
{
filterStartDate = today;
filterEndDate = today;
}
else if (filterPeriod == "monthly")
{
filterStartDate = new DateTime(today.Year, today.Month, 1);
filterEndDate = new DateTime(today.Year, today.Month, DateTime.DaysInMonth(today.Year, today.Month));
}
else if (filterPeriod == "yearly")
{
filterStartDate = new DateTime(today.Year, 1, 1);
filterEndDate = new DateTime(today.Year, 12, 31);
}
else
{
filterStartDate = null;
filterEndDate = null;
}
}
private async Task LoadEmployeeReportData()
{
var client = ApiClient.CreateClient();
try
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(searchQuery)) parts.Add($"search={Uri.EscapeDataString(searchQuery)}");
if (filterStartDate.HasValue) parts.Add($"startDate={filterStartDate:yyyy-MM-dd}");
if (filterEndDate.HasValue) parts.Add($"endDate={filterEndDate:yyyy-MM-dd}");
if (!string.IsNullOrWhiteSpace(filterStatus) && filterStatus != "0") parts.Add($"status={Uri.EscapeDataString(filterStatus)}");
if (filterAssignToMe) parts.Add("assignedToMe=true");
if (filterAssignToMyTeam) parts.Add("assignedToMyTeam=true");
// 1. Fetch full matching list for chart view and exports
var fullUrl = "Report/employee-productivity" + (parts.Count > 0 ? "?" + string.Join("&", parts) : "");
var fullResult = await client.GetFromJsonAsync<Result<List<EmployeeProductivityReportDto>>>(fullUrl, Serialization.CaseInsensitive);
if (fullResult != null && fullResult.IsSuccess && fullResult.Value != null)
{
allEmployees = fullResult.Value;
}
// 2. Fetch paged list for table pagination
var pagedParts = new List<string>(parts);
pagedParts.Add($"page={currentPage}");
pagedParts.Add($"limit={pageSize}");
var pagedUrl = "Report/employee-productivity" + (pagedParts.Count > 0 ? "?" + string.Join("&", pagedParts) : "");
var pagedResult = await client.GetFromJsonAsync<PagedResult<EmployeeProductivityReportDto>>(pagedUrl, Serialization.CaseInsensitive);
if (pagedResult != null)
{
reportPage = pagedResult;
pagedEmployees = pagedResult.Items.ToList();
currentPage = pagedResult.Page;
pageSize = pagedResult.PageSize;
}
}
catch (Exception ex)
{
Console.WriteLine($"Error loading employee report: {ex.Message}");
}
}
private async Task DownloadExcel()
{
var rows = allEmployees.Select(emp => new object?[]
{
emp.FullName,
emp.Username,
emp.AssignedIssues,
emp.OpenIssues,
emp.OverdueIssues,
emp.ActualHours,
emp.WorkloadLevel,
emp.Trend
});
var workbookBytes = SpreadsheetReportBuilder.BuildTableWorkbook(
"Employee Workload",
"Employee Workload & Performance Report",
new[] { "Employee", "Username", "Assigned Issues", "Open Issues", "Overdue Issues", "Actual Hours", "Workload", "Trend" },
rows,
new[]
{
$"Search: {(string.IsNullOrWhiteSpace(searchQuery) ? "All employees" : searchQuery)}",
$"Period: {filterPeriod}",
$"Task Status: {filterStatus switch { "0" => "All", "1" => "To Do", "2" => "In Progress", "3" => "Done", "overdue" => "Overdue", _ => filterStatus }}",
$"Generated: {DisplayFormats.Date(DateTime.Today)}"
});
await JS.InvokeVoidAsync(
"downloadFileFromBase64",
$"EmployeeWorkloadPerformance_{DateTime.Today:yyyyMMdd}.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
Convert.ToBase64String(workbookBytes));
}
private async Task DownloadPdf()
{
var summaryLines = new List<string>
{
$"Search: {(string.IsNullOrWhiteSpace(searchQuery) ? "All employees" : searchQuery)}",
$"Period: {filterPeriod}",
$"Task Status: {filterStatus switch { "0" => "All", "1" => "To Do", "2" => "In Progress", "3" => "Done", "overdue" => "Overdue", _ => filterStatus }}",
$"Generated: {DisplayFormats.Date(DateTime.Today)}"
};
var rows = allEmployees.Select(emp => new[]
{
emp.FullName,
emp.Username,
emp.AssignedIssues.ToString(),
emp.OpenIssues.ToString(),
emp.OverdueIssues.ToString(),
emp.ActualHours.ToString("N1"),
emp.WorkloadLevel,
emp.Trend
});
var pdfBytes = SimplePdfReportBuilder.BuildTableReport(
"Employee Workload & Performance Report",
summaryLines,
new[] { "Employee", "Username", "Assigned Issues", "Open", "Overdue", "Hours", "Workload", "Trend" },
rows);
await JS.InvokeVoidAsync(
"downloadFileFromBase64",
$"EmployeeWorkloadPerformance_{DateTime.Today:yyyyMMdd}.pdf",
"application/pdf",
Convert.ToBase64String(pdfBytes));
}
private static string GetWorkloadBadge(string workload) => workload switch
{
"High" => "bg-rose-50 text-rose-700",
"Light" => "bg-slate-100 text-slate-600",
_ => "bg-emerald-50 text-emerald-700"
};
private static string GetTrendBadge(string trend) => trend switch
{
"Improving" => "bg-emerald-50 text-emerald-700",
"Declining" => "bg-rose-50 text-rose-700",
_ => "bg-blue-50 text-blue-700"
};
private static string BuildShortName(string fullName)
{
var parts = fullName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length == 0 ? "User" : parts.Length == 1 ? parts[0] : $"{parts[0]} {parts[^1][0]}.";
}
}