User
add: language switch
8c88a89
Raw
History Blame Contribute Delete
33.5 kB
@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
<PageTitle>@AppLocalization.Text("report.timeTrackingTitle", "Time & Effort Tracking Report")</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.timeTrackingHeading", "Time & Effort Tracking")</h2>
<p class="text-slate-500 mt-1">@AppLocalization.Text("report.timeTrackingDesc", "Review estimated hours, completion timelines, and resource effort distribution across issues.")</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>
<!-- Stats summary -->
@if (!isLoading)
{
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="rounded-xl border border-violet-100 bg-violet-50 p-5 shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-violet-500">Total Estimated Effort</p>
<p class="mt-1 text-3xl font-bold text-violet-700">@totalHours.ToString("N1") hrs</p>
</div>
<span class="flex h-11 w-11 items-center justify-center rounded-xl bg-violet-100">
<i data-lucide="clock" class="h-6 w-6 text-violet-600"></i>
</span>
</div>
</div>
<div class="rounded-xl border border-emerald-100 bg-emerald-50 p-5 shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-emerald-500">Actual Hours</p>
<p class="mt-1 text-3xl font-bold text-emerald-700">@actualHours.ToString("N1") hrs</p>
</div>
<span class="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-100">
<i data-lucide="check-circle-2" class="h-6 w-6 text-emerald-600"></i>
</span>
</div>
</div>
<div class="rounded-xl border border-amber-100 bg-amber-50 p-5 shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-amber-500">Variance</p>
<p class="mt-1 text-3xl font-bold text-amber-700">@varianceHours.ToString("+0.0;-0.0;0.0") hrs</p>
</div>
<span class="flex h-11 w-11 items-center justify-center rounded-xl bg-amber-100">
<i data-lucide="timer" class="h-6 w-6 text-amber-600"></i>
</span>
</div>
</div>
<div class="rounded-xl border border-rose-100 bg-rose-50 p-5 shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-rose-500">Utilization</p>
<p class="mt-1 text-3xl font-bold text-rose-700">@utilizationPercent.ToString("N0")%</p>
</div>
<span class="flex h-11 w-11 items-center justify-center rounded-xl bg-rose-100">
<i data-lucide="alert-triangle" class="h-6 w-6 text-rose-600"></i>
</span>
</div>
</div>
</div>
}
<!-- Filters -->
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="flex flex-col gap-4">
<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("common.searchByIssueAssignee", "Search Issues / Assignee")</label>
<input @bind="searchInput" type="text" placeholder="@AppLocalization.Text("common.searchIssues", "Search issue title, description or assignee...")"
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">Project</label>
<select @bind="filterProjectId" 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.allProjects", "All Projects")</option>
@foreach (var proj in allProjects)
{
<option value="@proj.Id">@proj.Name</option>
}
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.status", "Issue 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.all", "All Statuses")</option>
<option value="Todo">@AppLocalization.StatusLabel(AppTaskStatus.Todo)</option>
<option value="InProgress">@AppLocalization.StatusLabel(AppTaskStatus.InProgress)</option>
<option value="Done">@AppLocalization.StatusLabel(AppTaskStatus.Done)</option>
</select>
</div>
<button @onclick="ApplyFilters" 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="ResetFilters" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50 transition-colors shadow-sm">
<i data-lucide="rotate-ccw" class="h-4 w-4 mr-2"></i> Reset
</button>
</div>
<div class="flex flex-wrap items-center gap-6 border-t border-slate-100 pt-3">
<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>
@if (isLoading)
{
<LoadingSpinner Message="@AppLocalization.Text("report.loadingTimesheet", "Loading timesheet data...")" />
}
else
{
@if (isChartView)
{
<div class="grid grid-cols-1 gap-5 xl:grid-cols-12">
<div class="xl:col-span-5">
<MetricComparisonCard Title="Estimated vs actual effort"
Subtitle="Are plans matching delivery effort?"
PrimaryLabel="Actual"
PrimaryValue="@($"{actualHours:N1}h")"
SecondaryLabel="Estimated"
SecondaryValue="@($"{totalHours:N1}h")"
Segments="EffortComparisonSegments"
Insight="@EffortVarianceInsight" />
</div>
<div class="xl:col-span-3">
<RadialMetricCard Title="Utilization gauge"
Subtitle="Actual hours as a share of estimated effort."
Percent="@utilizationPercent"
Value="@($"{varianceHours:N1}h")"
ValueLabel="variance"
CenterLabel="used"
Color="@UtilizationColor"
Insight="@UtilizationInsight" />
</div>
<div class="xl:col-span-4">
<ReportChartCard Title="Estimate accuracy" Subtitle="How many items stayed within estimate?" Value="@OnOrUnderEstimateCount.ToString()" ValueLabel="on / under" Insight="@EstimateAccuracyInsight">
<DonutStatusChart Items="EstimateAccuracyDonutItems" CenterLabel="issues" />
</ReportChartCard>
</div>
<div class="xl:col-span-7">
<ReportChartCard Title="Effort by project" Subtitle="Actual hours distributed across active projects." Insight="Use this to spot projects consuming more delivery capacity than expected.">
<StackedProgressBar Segments="ProjectEffortSegments" AriaLabel="Effort by project" />
</ReportChartCard>
</div>
<div class="xl:col-span-5">
<ReportChartCard Title="Effort leaderboard" Subtitle="People carrying the most tracked effort." Insight="Names highlight capacity allocation, not performance judgement.">
<TopRiskList Items="EmployeeEffortLeaders" EmptyText="No employee effort data available." />
</ReportChartCard>
</div>
</div>
}
else
{
<!-- Table -->
<div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-700">
Showing <span class="text-violet-600 font-bold">@FilteredIssues.Count()</span> timesheet issue entry(s)
</h3>
</div>
<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">Issue</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Task &amp; Project</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Assignee</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Est. Hours</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">Due Date</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@if (PagedIssues.Any())
{
@foreach (var entry in PagedIssues.Select((issue, index) => (issue, index)))
{
var issue = entry.issue;
<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>
<p class="text-sm font-semibold text-slate-900">@issue.Title</p>
@if (!string.IsNullOrEmpty(issue.Description))
{
<p class="text-xs text-slate-400 mt-0.5 truncate max-w-[200px]">@issue.Description</p>
}
</div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-slate-800 font-medium">@issue.TaskTitle</div>
<div class="text-xs text-slate-400 mt-0.5">@issue.ProjectName</div>
</td>
<td class="px-6 py-4">
@if (!string.IsNullOrEmpty(issue.AssignedToName))
{
<div class="flex items-center gap-2">
<span class="flex h-7 w-7 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-xs font-bold">
@(issue.AssignedToName.Length > 0 ? issue.AssignedToName[0].ToString().ToUpper() : "")
</span>
<span class="text-sm text-slate-700">@issue.AssignedToName</span>
</div>
}
else
{
<span class="text-xs text-slate-400">Unassigned</span>
}
</td>
<td class="px-6 py-4 text-sm font-semibold text-slate-800">
@((issue.EstimatedHours ?? 0).ToString("N1")) hrs
</td>
<td class="px-6 py-4 text-sm font-semibold @(GetVariance(issue) > 0 ? "text-rose-600" : "text-emerald-600")">
@((issue.ActualHours ?? 0).ToString("N1")) hrs
</td>
<td class="px-6 py-4 text-sm text-slate-600">
@DisplayFormats.Date(issue.DueDate)
</td>
<td class="px-6 py-4">
@{
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")
};
}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium @statusClass">@statusLabel</span>
</td>
</tr>
}
}
else
{
<EmptyState ColSpan="8" Icon="clock" Title="No effort entries" Subtitle="No timesheet effort entries matched your filters." />
}
</tbody>
</table>
<!-- Pagination -->
<Pagination CurrentPage="currentPage"
TotalPages="TotalPages"
PageSize="pageSize"
TotalCount="FilteredIssues.Count()"
OnPageChanged="HandlePageChanged"
OnPageSizeChanged="HandlePageSizeChanged" />
</div>
}
}
</div>
@code {
private bool isLoading = true;
private bool isChartView = false;
private List<IssueDto> allIssues = new();
private List<ProjectDto> 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<long> 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<IssueDto> FilteredIssues
{
get
{
var result = allIssues.AsEnumerable();
if (appliedProjectId > 0)
result = result.Where(i => i.ProjectId == appliedProjectId);
if (appliedStatus != "0" && Enum.TryParse<AppTaskStatus>(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<IssueDto> PagedIssues => FilteredIssues.Skip((currentPage - 1) * pageSize).Take(pageSize);
protected override async Task OnInitializedAsync()
{
var client = ApiClient.CreateClient();
try
{
var issuesTask = client.GetFromJsonAsync<Result<List<IssueDto>>>("Report/issues", Serialization.CaseInsensitive);
var projectsTask = client.GetFromJsonAsync<List<ProjectDto>>("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<List<long>>("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<StackedSegment> EffortComparisonSegments => new()
{
new("Actual", (double)Math.Max(0, actualHours), "#3b82f6"),
new("Remaining estimate", (double)Math.Max(0, totalHours - actualHours), "#dbeafe")
};
private List<ReportChartItem> EstimateAccuracyDonutItems => new()
{
new("On / Under", OnOrUnderEstimateCount, "#10b981"),
new("Over", OverEstimateCount, "#ef4444")
};
private List<StackedSegment> 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<TopRiskItem> 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<ProgressListChartItem> 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<ProgressListChartItem> 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<ProgressListChartItem> 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<ProgressListChartItem> 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<ProgressListChartItem> 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<string>
{
$"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));
}
}