@page "/reports/timesheet" @using Microsoft.AspNetCore.Authorization @using TaskTrackingSystem.Shared @using TaskTrackingSystem.Shared.Models.Issue @using TaskTrackingSystem.Shared.Models.User @using TaskTrackingSystem.Shared.Models.Project @using System.Text @rendermode @(new InteractiveServerRenderMode(prerender: false)) @attribute [Authorize] @inject ApiClientService ApiClient @inject IJSRuntime JS @inject AuthenticationStateProvider AuthStateProvider @inject MenuAuthorizationService MenuAuthorization @AppLocalization.Text("report.timeTrackingTitle", "Time & Effort Tracking Report")

@AppLocalization.Text("report.timeTrackingHeading", "Time & Effort Tracking")

@AppLocalization.Text("report.timeTrackingDesc", "Review estimated hours, completion timelines, and resource effort distribution across issues.")

@if (!isLoading) {

@AppLocalization.Text("report.totalEstimatedEffort", "Total Estimated Effort")

@AppLocalization.Number(totalHours, "N1") @AppLocalization.Text("common.hoursAbbrev", "hrs")

@AppLocalization.Text("common.actualHours", "Actual Hours")

@AppLocalization.Number(actualHours, "N1") @AppLocalization.Text("common.hoursAbbrev", "hrs")

@AppLocalization.Text("report.variance", "Variance")

@AppLocalization.LocalizeDigits(varianceHours.ToString("+0.0;-0.0;0.0", System.Globalization.CultureInfo.InvariantCulture)) @AppLocalization.Text("common.hoursAbbrev", "hrs")

@AppLocalization.Text("report.utilization", "Utilization")

@AppLocalization.Number(utilizationPercent, "N0")%

}
@if (isLoading) { } else { @if (isChartView) {
} else {

@AppLocalization.Text("pagination.showing", "Showing") @AppLocalization.Number(FilteredIssues.Count()) @AppLocalization.Text("report.timesheetEntries", "timesheet issue entry(s)")

@if (PagedIssues.Any()) { @foreach (var entry in PagedIssues.Select((issue, index) => (issue, index))) { var issue = entry.issue; } } else { }
@AppLocalization.Text("common.index", "No.") @AppLocalization.Text("common.issue", "Issue") @AppLocalization.Text("common.taskProject", "Task & Project") @AppLocalization.Text("common.assignee", "Assignee") @AppLocalization.Text("common.estimatedHours", "Est. Hours") @AppLocalization.Text("common.actualHours", "Actual Hours") @AppLocalization.Text("common.dueDate", "Due Date") @AppLocalization.Text("common.status", "Status")
@AppLocalization.Number((currentPage - 1) * pageSize + entry.index + 1)

@Content.GetIssueTitle(issue)

@if (!string.IsNullOrEmpty(Content.GetIssueDescription(issue))) {

@Content.GetIssueDescription(issue)

}
@Content.GetText(issue.TaskTitle, issue.TaskTitleMy)
@Content.GetText(issue.ProjectName, issue.ProjectNameMy)
@if (Content.GetText(issue.AssignedToName, issue.AssignedToNameMy) is { Length: > 0 } assigneeName) {
@assigneeName[0].ToString().ToUpper() @Content.GetText(issue.AssignedToName, issue.AssignedToNameMy)
} else { @AppLocalization.Text("common.unassigned", "Unassigned") }
@AppLocalization.Number(issue.EstimatedHours ?? 0, "N1") @AppLocalization.Text("common.hoursAbbrev", "hrs") @AppLocalization.Number(issue.ActualHours ?? 0, "N1") @AppLocalization.Text("common.hoursAbbrev", "hrs") @DisplayFormats.Date(issue.DueDate) @{ var (statusLabel, statusClass) = issue.StatusId switch { AppTaskStatus.InProgress => (AppLocalization.StatusLabel(AppTaskStatus.InProgress), "bg-blue-50 text-blue-700"), AppTaskStatus.Done => (AppLocalization.StatusLabel(AppTaskStatus.Done), "bg-emerald-50 text-emerald-700"), _ => (AppLocalization.StatusLabel(AppTaskStatus.Todo), "bg-slate-100 text-slate-600") }; } @statusLabel
} }
@code { private bool isLoading = true; private bool isChartView = false; private List allIssues = new(); private List allProjects = new(); // Filters private string searchInput = ""; private string appliedSearch = ""; private long filterProjectId = 0; private long appliedProjectId = 0; private string filterStatus = "0"; private string appliedStatus = "0"; private bool filterAssignToMe = false; private bool filterAssignToMyTeam = false; // Current User & Team private long currentUserId = 0; private List myTeamUserIds = new(); // Pagination private int currentPage = 1; private int pageSize = 10; // Metrics private decimal totalHours = 0; private decimal actualHours = 0; private decimal varianceHours = 0; private double utilizationPercent = 0; private int TotalPages => (int)Math.Ceiling((double)FilteredIssues.Count() / pageSize); private IEnumerable FilteredIssues { get { var result = allIssues.AsEnumerable(); if (appliedProjectId > 0) result = result.Where(i => i.ProjectId == appliedProjectId); if (appliedStatus != "0" && Enum.TryParse(appliedStatus, out var status)) result = result.Where(i => i.StatusId == status); if (!string.IsNullOrWhiteSpace(appliedSearch)) { var q = appliedSearch.Trim().ToLower(); result = result.Where(i => ContainsSearch(i.Title, i.TitleMy, q) || ContainsSearch(i.Description, i.DescriptionMy, q) || ContainsSearch(i.AssignedToName, i.AssignedToNameMy, q) || ContainsSearch(i.TaskTitle, i.TaskTitleMy, q) ); } if (filterAssignToMe || filterAssignToMyTeam) { result = result.Where(i => (filterAssignToMe && i.AssignedTo == currentUserId) || (filterAssignToMyTeam && i.AssignedTo.HasValue && myTeamUserIds.Contains(i.AssignedTo.Value)) ); } return result.OrderBy(i => i.Title); } } private static bool ContainsSearch(string? english, string? burmese, string search) => (!string.IsNullOrWhiteSpace(english) && english.Contains(search, StringComparison.OrdinalIgnoreCase)) || (!string.IsNullOrWhiteSpace(burmese) && burmese.Contains(search, StringComparison.OrdinalIgnoreCase)); private IEnumerable PagedIssues => FilteredIssues.Skip((currentPage - 1) * pageSize).Take(pageSize); protected override async Task OnInitializedAsync() { var client = ApiClient.CreateClient(); try { var issuesTask = client.GetFromJsonAsync>>("Report/issues", Serialization.CaseInsensitive); var projectsTask = client.GetFromJsonAsync>("Project", Serialization.CaseInsensitive); // Get current user ID var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var user = authState.User; if (user.Identity?.IsAuthenticated == true) { var idStr = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; if (long.TryParse(idStr, out var parsedId)) { currentUserId = parsedId; } } // Get team IDs try { var teamTask = client.GetFromJsonAsync>("User/my-team-user-ids", Serialization.CaseInsensitive); await Task.WhenAll(issuesTask, projectsTask, teamTask); myTeamUserIds = teamTask.Result ?? new(); } catch { await Task.WhenAll(issuesTask, projectsTask); } var issuesResult = issuesTask.Result; if (issuesResult?.IsSuccess == true && issuesResult.Value != null) { allIssues = issuesResult.Value; } allProjects = projectsTask.Result ?? new(); ComputeMetrics(); } catch (Exception ex) { Console.WriteLine($"Error fetching timesheet data: {ex.Message}"); } finally { isLoading = false; } } protected override async Task OnAfterRenderAsync(bool firstRender) { await JS.InvokeVoidAsync("initIcons"); } private void SetView(bool chartView) { isChartView = chartView; } private void ComputeMetrics() { var today = DateTime.Today; var list = FilteredIssues.ToList(); totalHours = list.Sum(i => i.EstimatedHours ?? 0); actualHours = list.Sum(i => i.ActualHours ?? 0); varianceHours = actualHours - totalHours; utilizationPercent = totalHours > 0 ? Math.Round((double)(actualHours / totalHours) * 100, 1) : 0; } private static decimal GetVariance(IssueDto issue) => (issue.ActualHours ?? 0) - (issue.EstimatedHours ?? 0); private int OnOrUnderEstimateCount => FilteredIssues.Count(i => (i.ActualHours ?? 0) <= (i.EstimatedHours ?? 0)); private int OverEstimateCount => FilteredIssues.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0)); private string UtilizationColor => utilizationPercent > 115 ? "#ef4444" : utilizationPercent > 95 ? "#f59e0b" : "#10b981"; private string EffortVarianceInsight => varianceHours > 0 ? AppLocalization.Text("report.actualEffortAboveEstimate", "Actual effort is {hours}h above estimate.").Replace("{hours}", AppLocalization.Number(varianceHours)) : AppLocalization.Text("report.actualEffortUnderEstimate", "Actual effort is {hours}h under estimate.").Replace("{hours}", AppLocalization.Number(Math.Abs(varianceHours))); private string UtilizationInsight => utilizationPercent > 115 ? AppLocalization.Text("report.utilizationAbovePlan", "Utilization is above plan; review estimates and blockers.") : utilizationPercent < 70 ? AppLocalization.Text("report.utilizationBelowPlan", "Tracked actual effort is below plan; confirm work is being logged.") : AppLocalization.Text("report.utilizationCloseToPlan", "Utilization is close to the planning range."); private string EstimateAccuracyInsight => OverEstimateCount > OnOrUnderEstimateCount ? AppLocalization.Text("report.accuracyExceedingEstimate", "More issues are exceeding estimate than staying within it.") : AppLocalization.Text("report.accuracyWithinEstimate", "Most issues are within or under their estimates."); private List EffortComparisonSegments => new() { new(AppLocalization.Text("report.actual", "Actual"), (double)Math.Max(0, actualHours), "#3b82f6"), new(AppLocalization.Text("report.remainingEstimate", "Remaining estimate"), (double)Math.Max(0, totalHours - actualHours), "#dbeafe") }; private List EstimateAccuracyDonutItems => new() { new(AppLocalization.Text("report.onOrUnder", "On / Under"), OnOrUnderEstimateCount, "#10b981"), new(AppLocalization.Text("report.overLabel", "Over"), OverEstimateCount, "#ef4444") }; private List ProjectEffortSegments => FilteredIssues .GroupBy(i => Content.GetText(i.ProjectName, i.ProjectNameMy)) .OrderByDescending(g => g.Sum(i => i.ActualHours ?? 0)) .Take(6) .Select((g, index) => new StackedSegment(g.Key, (double)g.Sum(i => i.ActualHours ?? 0), ChartPalette[index % ChartPalette.Length])) .ToList(); private List EmployeeEffortLeaders => FilteredIssues .GroupBy(i => string.IsNullOrWhiteSpace(Content.GetText(i.AssignedToName, i.AssignedToNameMy)) ? AppLocalization.Text("common.unassigned", "Unassigned") : Content.GetText(i.AssignedToName, i.AssignedToNameMy)) .Select(g => { var actual = g.Sum(i => i.ActualHours ?? 0); var estimated = g.Sum(i => i.EstimatedHours ?? 0); var score = estimated > 0 ? Math.Min(100, (double)(actual / estimated) * 100) : Math.Min(100, (double)actual * 10); var tone = score > 115 ? "rose" : score > 90 ? "amber" : "blue"; return new TopRiskItem( g.Key, $"{AppLocalization.Number(actual)} {AppLocalization.Text("common.hoursAbbrev", "hrs")} {AppLocalization.Text("report.actual", "actual").ToLowerInvariant()} ยท {AppLocalization.Number(estimated)} {AppLocalization.Text("common.hoursAbbrev", "hrs")} {AppLocalization.Text("report.estimated", "estimated").ToLowerInvariant()}", score, $"{AppLocalization.Number(g.Count())} {AppLocalization.Text("report.issuesLabel", "issues")}", tone, $"{AppLocalization.Number(g.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0)))} {AppLocalization.Text("report.overEstimateDesc", "over estimate")}"); }) .OrderByDescending(item => item.Score) .Take(5) .ToList(); private static readonly string[] ChartPalette = ["#7c3aed", "#3b82f6", "#06b6d4", "#10b981", "#f59e0b", "#ef4444"]; private List EstimatedVsActualItems => new[] { new ProgressListChartItem(AppLocalization.Text("report.estimated", "Estimated"), (double)totalHours, $"{AppLocalization.Number(totalHours)}", "#7c3aed"), new ProgressListChartItem(AppLocalization.Text("report.actual", "Actual"), (double)actualHours, $"{AppLocalization.Number(actualHours)}", "#06b6d4") }.ToList(); private double EstimatedVsActualMax => GetMax(EstimatedVsActualItems); private List EffortByProjectItems => FilteredIssues .GroupBy(i => Content.GetText(i.ProjectName, i.ProjectNameMy)) .OrderByDescending(g => g.Sum(i => i.ActualHours ?? 0)) .Take(5) .Select(g => { var hours = (double)g.Sum(i => i.ActualHours ?? 0); return new ProgressListChartItem(g.Key, hours, $"{AppLocalization.Number(hours, "N1")}", "#7c3aed"); }) .ToList(); private double EffortByProjectMax => GetMax(EffortByProjectItems); private List EffortByEmployeeItems => FilteredIssues .GroupBy(i => string.IsNullOrWhiteSpace(Content.GetText(i.AssignedToName, i.AssignedToNameMy)) ? AppLocalization.Text("common.unassigned", "Unassigned") : Content.GetText(i.AssignedToName, i.AssignedToNameMy)) .OrderByDescending(g => g.Sum(i => i.ActualHours ?? 0)) .Take(5) .Select(g => { var hours = (double)g.Sum(i => i.ActualHours ?? 0); return new ProgressListChartItem(g.Key, hours, $"{AppLocalization.Number(hours)}", "#7c3aed"); }) .ToList(); private double EffortByEmployeeMax => GetMax(EffortByEmployeeItems); private List EstimateAccuracyItems => new[] { new ProgressListChartItem(AppLocalization.Text("report.onOrUnder", "On / Under"), FilteredIssues.Count(i => (i.ActualHours ?? 0) <= (i.EstimatedHours ?? 0)), AppLocalization.Number(FilteredIssues.Count(i => (i.ActualHours ?? 0) <= (i.EstimatedHours ?? 0))), "#10b981"), new ProgressListChartItem(AppLocalization.Text("report.overLabel", "Over"), FilteredIssues.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0)), AppLocalization.Number(FilteredIssues.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0))), "#ef4444") }.ToList(); private double EstimateAccuracyMax => GetMax(EstimateAccuracyItems); private static double GetMax(IEnumerable items) { return Math.Max(1, items.Select(item => item.Value).DefaultIfEmpty(0).Max()); } private Task ApplyFilters() { appliedSearch = searchInput; appliedProjectId = filterProjectId; appliedStatus = filterStatus; currentPage = 1; ComputeMetrics(); return Task.CompletedTask; } private Task ResetFilters() { searchInput = ""; appliedSearch = ""; filterProjectId = 0; appliedProjectId = 0; filterStatus = "0"; appliedStatus = "0"; filterAssignToMe = false; filterAssignToMyTeam = false; currentPage = 1; ComputeMetrics(); return Task.CompletedTask; } private Task HandlePageChanged(int page) { currentPage = page; return Task.CompletedTask; } private Task HandlePageSizeChanged(int size) { pageSize = size; currentPage = 1; return Task.CompletedTask; } private async Task DownloadExcel() { var rows = FilteredIssues.Select(issue => { var statusLabel = AppLocalization.StatusLabel(issue.StatusId); return new object?[] { Content.GetText(issue.Title, issue.TitleMy), Content.GetText(issue.TaskTitle, issue.TaskTitleMy), Content.GetText(issue.ProjectName, issue.ProjectNameMy), string.IsNullOrWhiteSpace(Content.GetText(issue.AssignedToName, issue.AssignedToNameMy)) ? "Unassigned" : Content.GetText(issue.AssignedToName, issue.AssignedToNameMy), issue.EstimatedHours ?? 0, issue.DueDate, statusLabel }; }); var workbookBytes = SpreadsheetReportBuilder.BuildTableWorkbook( "Time Effort Tracking", "Timesheet Effort Report", new[] { "Issue", "Task", "Project", "Assignee", "Estimated Effort (Hours)", "Due Date", "Status" }, rows, new[] { $"Search: {(string.IsNullOrWhiteSpace(appliedSearch) ? "All" : appliedSearch)}", $"Project: {(appliedProjectId > 0 && allProjects.FirstOrDefault(p => p.Id == appliedProjectId) is { } project ? Content.GetProjectName(project) : "All")}", $"Status: {(appliedStatus != "0" ? appliedStatus : "All")}", $"Assigned To Me: {(filterAssignToMe ? "Yes" : "No")}", $"Assigned To My Team: {(filterAssignToMyTeam ? "Yes" : "No")}", $"Generated: {DisplayFormats.Date(DateTime.Today)}" }); await JS.InvokeVoidAsync( "downloadFileFromBase64", $"TimesheetEffortReport_{DateTime.Today:yyyyMMdd}.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", Convert.ToBase64String(workbookBytes)); } private async Task DownloadPdf() { var summaryLines = new List { $"Search: {(string.IsNullOrWhiteSpace(appliedSearch) ? "All" : appliedSearch)}", $"Project: {(appliedProjectId > 0 && allProjects.FirstOrDefault(p => p.Id == appliedProjectId) is { } project ? Content.GetProjectName(project) : "All")}", $"Status: {(appliedStatus != "0" ? appliedStatus : "All")}", $"Assigned To Me: {(filterAssignToMe ? "Yes" : "No")}", $"Assigned To My Team: {(filterAssignToMyTeam ? "Yes" : "No")}", $"Total Effort: {totalHours.ToString("N1")} hrs", $"Actual Hours: {actualHours.ToString("N1")} hrs", $"Generated: {DisplayFormats.Date(DateTime.Today)}" }; var rows = FilteredIssues.Select(issue => { var statusLabel = AppLocalization.StatusLabel(issue.StatusId); return new[] { Content.GetText(issue.Title, issue.TitleMy), Content.GetText(issue.TaskTitle, issue.TaskTitleMy), Content.GetText(issue.ProjectName, issue.ProjectNameMy), string.IsNullOrWhiteSpace(Content.GetText(issue.AssignedToName, issue.AssignedToNameMy)) ? "Unassigned" : Content.GetText(issue.AssignedToName, issue.AssignedToNameMy), (issue.EstimatedHours ?? 0).ToString("N1") + " hrs", DisplayFormats.Date(issue.DueDate), statusLabel }; }); var pdfBytes = SimplePdfReportBuilder.BuildTableReport( "Timesheet Effort Tracking Report", summaryLines, new[] { "Issue", "Task", "Project", "Assignee", "Est. Effort", "Due Date", "Status" }, rows); await JS.InvokeVoidAsync( "downloadFileFromBase64", $"TimesheetEffortReport_{DateTime.Today:yyyyMMdd}.pdf", "application/pdf", Convert.ToBase64String(pdfBytes)); } }