@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) {

Total Estimated Effort

@totalHours.ToString("N1") hrs

Actual Hours

@actualHours.ToString("N1") hrs

Variance

@varianceHours.ToString("+0.0;-0.0;0.0") hrs

Utilization

@utilizationPercent.ToString("N0")%

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

Showing @FilteredIssues.Count() timesheet issue entry(s)

@if (PagedIssues.Any()) { @foreach (var entry in PagedIssues.Select((issue, index) => (issue, index))) { var issue = entry.issue; } } else { }
No. Issue Task & Project Assignee Est. Hours Actual Hours Due Date Status
@((currentPage - 1) * pageSize + entry.index + 1)

@issue.Title

@if (!string.IsNullOrEmpty(issue.Description)) {

@issue.Description

}
@issue.TaskTitle
@issue.ProjectName
@if (!string.IsNullOrEmpty(issue.AssignedToName)) {
@(issue.AssignedToName.Length > 0 ? issue.AssignedToName[0].ToString().ToUpper() : "") @issue.AssignedToName
} else { Unassigned }
@((issue.EstimatedHours ?? 0).ToString("N1")) hrs @((issue.ActualHours ?? 0).ToString("N1")) hrs @DisplayFormats.Date(issue.DueDate) @{ var (statusLabel, statusClass) = issue.StatusId switch { AppTaskStatus.InProgress => ("In Progress", "bg-blue-50 text-blue-700"), AppTaskStatus.Done => ("Completed", "bg-emerald-50 text-emerald-700"), _ => ("To Do", "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 => i.Title.ToLower().Contains(q) || (i.Description != null && i.Description.ToLower().Contains(q)) || (i.AssignedToName != null && i.AssignedToName.ToLower().Contains(q)) || i.TaskTitle.ToLower().Contains(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 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 ? $"Actual effort is {varianceHours:N1}h above estimate." : $"Actual effort is {Math.Abs(varianceHours):N1}h under estimate."; private string UtilizationInsight => utilizationPercent > 115 ? "Utilization is above plan; review estimates and blockers." : utilizationPercent < 70 ? "Tracked actual effort is below plan; confirm work is being logged." : "Utilization is close to the planning range."; private string EstimateAccuracyInsight => OverEstimateCount > OnOrUnderEstimateCount ? "More issues are exceeding estimate than staying within it." : "Most issues are within or under their estimates."; private List EffortComparisonSegments => new() { new("Actual", (double)Math.Max(0, actualHours), "#3b82f6"), new("Remaining estimate", (double)Math.Max(0, totalHours - actualHours), "#dbeafe") }; private List EstimateAccuracyDonutItems => new() { new("On / Under", OnOrUnderEstimateCount, "#10b981"), new("Over", OverEstimateCount, "#ef4444") }; private List ProjectEffortSegments => FilteredIssues .GroupBy(i => i.ProjectName) .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 => i.AssignedToName ?? "Unassigned") .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, $"{actual:N1}h actual ยท {estimated:N1}h estimated", score, $"{g.Count()} issues", tone, $"{g.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0))} 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("Estimated", (double)totalHours, $"{totalHours:N1}", "#7c3aed"), new ProgressListChartItem("Actual", (double)actualHours, $"{actualHours:N1}", "#06b6d4") }.ToList(); private double EstimatedVsActualMax => GetMax(EstimatedVsActualItems); private List EffortByProjectItems => FilteredIssues .GroupBy(i => i.ProjectName) .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, $"{hours:N1}", "#7c3aed"); }) .ToList(); private double EffortByProjectMax => GetMax(EffortByProjectItems); private List EffortByEmployeeItems => FilteredIssues .GroupBy(i => i.AssignedToName ?? "Unassigned") .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, $"{hours:N1}", "#7c3aed"); }) .ToList(); private double EffortByEmployeeMax => GetMax(EffortByEmployeeItems); private List EstimateAccuracyItems => new[] { new ProgressListChartItem("On / Under", FilteredIssues.Count(i => (i.ActualHours ?? 0) <= (i.EstimatedHours ?? 0)), FilteredIssues.Count(i => (i.ActualHours ?? 0) <= (i.EstimatedHours ?? 0)).ToString(), "#10b981"), new ProgressListChartItem("Over", FilteredIssues.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0)), FilteredIssues.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0)).ToString(), "#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 = issue.StatusId switch { AppTaskStatus.InProgress => "In Progress", AppTaskStatus.Done => "Completed", _ => "To Do" }; return new object?[] { issue.Title, issue.TaskTitle, issue.ProjectName, issue.AssignedToName ?? "Unassigned", 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)?.Name ?? "All" : "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)?.Name ?? "All" : "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 = issue.StatusId switch { AppTaskStatus.InProgress => "In Progress", AppTaskStatus.Done => "Completed", _ => "To Do" }; return new[] { issue.Title, issue.TaskTitle, issue.ProjectName, issue.AssignedToName ?? "Unassigned", (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)); } }