User
add: language switch
8c88a89
Raw
History Blame Contribute Delete
43.8 kB
@page "/reports/tasks"
@using Microsoft.AspNetCore.Authorization
@using TaskTrackingSystem.Shared
@using TaskTrackingSystem.Shared.Models.Report
@using TaskTrackingSystem.Shared.Enums
@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.PageTitle("taskReport", "Task 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.PageTitle("taskReport", "Task Report")</h2>
<p class="text-slate-500 mt-1">@AppLocalization.PageDescription("taskReport", "Analyze task activity and export task reports.")</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 Section -->
<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("common.searchTasks", "Search Task")</label>
<input @bind="searchInput" type="text" placeholder="@AppLocalization.Text("common.searchByTitle", "Search by title or project...")" 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">@AppLocalization.Text("report.daily", "Daily (Today)")</option>
<option value="monthly">@AppLocalization.Text("report.monthly", "Monthly (This Month)")</option>
<option value="yearly">@AppLocalization.Text("report.yearly", "Yearly (This Year)")</option>
<option value="custom">@AppLocalization.Text("report.customRange", "Custom Range")</option>
</select>
</div>
@if (filterPeriod == "custom")
{
<div>
<label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.startDate", "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">@AppLocalization.Text("common.endDate", "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", "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="uncompleted">@AppLocalization.Text("status.uncompleted", "Uncompleted")</option>
<option value="">@AppLocalization.Text("common.all", "All")</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>
<div class="relative min-w-[240px]">
<label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.project", "Project")</label>
<div class="relative">
<input @bind="projectSearchInput"
@onfocus="ShowProjectSuggestions"
@onblur="HideProjectSuggestionsDelayed"
type="text"
placeholder="@AppLocalization.Text("common.searchProjects", "Search projects...")"
autocomplete="off"
class="w-full rounded-lg border border-slate-200 px-3 py-2 pr-8 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" />
@if (filterProjectId > 0 || !string.IsNullOrWhiteSpace(projectSearchInput))
{
<button type="button"
@onmousedown="ClearProjectFilter"
class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
title="@AppLocalization.Text("common.clear", "Clear project filter")">
<i data-lucide="x" class="h-4 w-4"></i>
</button>
}
</div>
@if (showProjectSuggestions && FilteredProjects.Any())
{
<ul class="absolute z-20 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-slate-200 bg-white py-1 shadow-lg">
<li>
<button type="button"
@onmousedown="ClearProjectFilter"
class="w-full px-3 py-2 text-left text-sm text-slate-600 hover:bg-slate-50 transition-colors">
@AppLocalization.Text("common.all", "All Projects")
</button>
</li>
@foreach (var p in FilteredProjects.Take(10))
{
<li>
<button type="button"
@onmousedown="() => SelectProject(p)"
class="w-full px-3 py-2 text-left text-sm text-slate-900 hover:bg-slate-50 transition-colors @(filterProjectId == p.Id ? "bg-slate-50 font-medium" : "")">
@p.Name
</button>
</li>
}
</ul>
}
</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> @AppLocalization.Text("action.search", "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> @AppLocalization.Text("action.reset", "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>
@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 (!string.IsNullOrWhiteSpace(errorMessage))
{
<div class="rounded-xl border border-rose-200 bg-rose-50 p-5 text-sm text-rose-700 shadow-sm">
<div class="flex items-start gap-3">
<i data-lucide="alert-triangle" class="mt-0.5 h-5 w-5 shrink-0"></i>
<div>
<p class="font-semibold">@AppLocalization.Text("report.couldNotLoad", "Task report could not load.")</p>
<p class="mt-1">@errorMessage</p>
</div>
</div>
</div>
}
else
{
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
@TaskMetricCard(AppLocalization.Text("report.total", "Total"), FilteredList.Count().ToString(), "file-text", "violet")
@TaskMetricCard(AppLocalization.Text("status.done", "Completed"), FilteredList.Count(t => t.StatusId == AppTaskStatus.Done).ToString(), "check-circle-2", "emerald")
@TaskMetricCard(AppLocalization.StatusLabel(AppTaskStatus.InProgress), FilteredList.Count(t => t.StatusId == AppTaskStatus.InProgress).ToString(), "timer", "blue")
@TaskMetricCard(AppLocalization.Text("status.overdue", "Overdue"), FilteredList.Count(t => t.DueDate.Date < DateTime.Today && t.StatusId != AppTaskStatus.Done).ToString(), "alarm-clock", "rose")
</div>
<InsightPanel Insights="TaskInsights.ToList()" />
@if (isChartView)
{
<div class="grid grid-cols-1 gap-5 xl:grid-cols-12">
<div class="xl:col-span-5">
<ReportChartCard Title="@AppLocalization.Text("report.taskStatusMix", "Task status mix")" Subtitle="@AppLocalization.Text("report.taskStatusMixDesc", "Where execution currently sits across the task lifecycle.")" Insight="@TaskStatusInsight">
<DonutStatusChart Items="TaskStatusItems" CenterLabel="@AppLocalization.Text("common.task", "tasks")" />
</ReportChartCard>
</div>
<div class="xl:col-span-4">
<ReportChartCard Title="@AppLocalization.Text("report.priorityDistribution", "Priority distribution")" Subtitle="@AppLocalization.Text("report.priorityDistributionDesc", "Planning pressure by task priority.")" Value="@HighPriorityCount.ToString()" ValueLabel="@AppLocalization.Text("priority.high", "high priority")" Insight="@PriorityInsight">
<StackedProgressBar Segments="PrioritySegments" AriaLabel="Task priority distribution" />
</ReportChartCard>
</div>
<div class="xl:col-span-3">
<TrendSparklineCard Title="@AppLocalization.Text("report.completionMovement", "Completion movement")" Subtitle="@AppLocalization.Text("report.completionMovementDesc", "Recently completed tasks by creation month.")" Value="@($"{CompletionRate:0}%")" ValueLabel="@AppLocalization.Text("report.completion", "completion")" Points="CompletionSparkline" Color="#10b981" Insight="@CompletionInsight" />
</div>
<div class="xl:col-span-6">
<ReportChartCard Title="@AppLocalization.Text("report.topProjectRisk", "Top project risk")" Subtitle="@AppLocalization.Text("report.topProjectRiskDesc", "Projects with the highest task and issue pressure.")" Insight="@AppLocalization.Text("report.topProjectRiskInsight", "Score combines task volume, open issues, overdue issues, and critical task health.")">
<TopRiskList Items="ProjectRiskItems" EmptyText="@AppLocalization.Text("common.noResults", "No project risk in this filtered set.")" />
</ReportChartCard>
</div>
<div class="xl:col-span-6">
<ReportChartCard Title="@AppLocalization.Text("report.tasksWithMostIssues", "Tasks with most open issues")" Subtitle="@AppLocalization.Text("report.tasksWithMostIssuesDesc", "Execution blockers that may need issue cleanup before progress moves.")" Insight="@AppLocalization.Text("report.tasksWithMostIssuesInsight", "Use this to decide where issue triage creates the fastest task movement.")">
<TopRiskList Items="TaskIssueRiskItems" EmptyText="@AppLocalization.Text("common.noResults", "No open issue pressure in this filtered set.")" />
</ReportChartCard>
</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">@AppLocalization.Text("common.index", "No.")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.task", "Task")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.project", "Project")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.status", "Status")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.priority", "Priority")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.health", "Health")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.issue", "Issues")</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@if (PagedList.Any())
{
@foreach (var entry in PagedList.Select((t, index) => (t, index)))
{
var t = entry.t;
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="px-6 py-3 text-sm font-semibold text-slate-500">@((currentPage - 1) * pageSize + entry.index + 1)</td>
<td class="px-6 py-3">
<button @onclick="() => ToggleExpanded(t.TaskId)" class="text-left text-sm font-medium text-slate-900 hover:text-violet-600">@t.Title</button>
</td>
<td class="px-6 py-3 text-sm text-slate-500">@t.ProjectName</td>
<td class="px-6 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium @GetStatusBadge(t.StatusId)">@AppLocalization.StatusLabel(t.StatusId)</span>
</td>
<td class="px-6 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium @GetPriorityBadge(t.PriorityId)">@AppLocalization.PriorityLabel(t.PriorityId)</span>
</td>
<td class="px-6 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold @GetHealthBadge(t.TaskHealth)">@GetHealthLabel(t.TaskHealth)</span>
</td>
<td class="px-6 py-3 text-sm text-slate-600">@t.IssueSummary</td>
</tr>
@if (expandedTasks.Contains(t.TaskId))
{
<tr class="bg-slate-50/60">
<td colspan="7" class="px-6 py-4">
<div class="grid grid-cols-1 gap-3 text-sm text-slate-600 md:grid-cols-4">
<div><span class="font-semibold text-slate-800">@AppLocalization.Text("common.assignee", "Assignee"):</span> @(t.AssignedToUser ?? AppLocalization.Text("common.unassigned", "Unassigned"))</div>
<div><span class="font-semibold text-slate-800">@AppLocalization.Text("common.due", "Due"):</span> @DisplayFormats.Date(t.DueDate)</div>
<div><span class="font-semibold text-slate-800">@AppLocalization.Text("report.openIssues", "Open issues"):</span> @t.OpenIssues</div>
<div><span class="font-semibold text-slate-800">@AppLocalization.Text("report.lastActivity", "Last activity"):</span> @DisplayFormats.Date(t.LastActivity)</div>
</div>
</td>
</tr>
}
}
}
else
{
<tr>
<td colspan="7" class="px-6 py-16 text-center bg-slate-50/20">
<div class="flex flex-col items-center justify-center space-y-4">
<svg class="w-24 h-24 text-slate-300" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="25" y="15" width="70" height="90" rx="8" stroke="currentColor" stroke-width="3" stroke-dasharray="6 4" />
<path d="M45 40H75" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
<path d="M45 55H75" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
<path d="M45 70H60" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
<circle cx="85" cy="85" r="15" fill="white" stroke="#111827" stroke-width="3" />
<path d="M80 85L90 85M85 80L85 90" stroke="#111827" stroke-width="3" stroke-linecap="round" />
</svg>
<h3 class="text-lg font-semibold text-slate-700">@AppLocalization.Text("report.noTasksFound", "No Tasks Found")</h3>
<p class="text-sm text-slate-400 max-w-xs">@AppLocalization.Text("report.noTasksFoundDesc", "There are no tasks matching your filters. Try adjusting your search query or filters.")</p>
</div>
</td>
</tr>
}
</tbody>
</table>
<!-- Pagination -->
<Pagination CurrentPage="currentPage"
TotalPages="TotalPages"
PageSize="pageSize"
TotalCount="taskReportPage?.TotalCount ?? 0"
OnPageChanged="HandlePageChanged"
OnPageSizeChanged="HandlePageSizeChanged" />
</div>
}
}
</div>
@code {
private bool isLoading = true;
private bool isChartView = false;
private List<TaskReportDto> taskReports = new();
private HashSet<long> expandedTasks = new();
private PagedResult<TaskReportDto>? taskReportPage;
private List<ProjectDto> projects = new();
private string? errorMessage;
// Filters
private string filterPeriod = "all";
private DateTime? filterStartDate;
private DateTime? filterEndDate;
private string? filterStatus = "uncompleted";
private int filterProjectId;
private string projectSearchInput = string.Empty;
private string projectSearchQuery = string.Empty;
private bool showProjectSuggestions;
private bool filterAssignToMe = false;
private bool filterAssignToMyTeam = false;
// Search and Pagination
private string searchInput = "";
private string searchQuery = "";
private int currentPage = 1;
private int pageSize = 10;
private async Task ApplyAllFilters()
{
ApplyPeriodPreset();
searchQuery = searchInput;
projectSearchQuery = projectSearchInput;
var matched = projects.FirstOrDefault(p => p.Name.Equals(projectSearchInput, StringComparison.OrdinalIgnoreCase));
if (matched != null)
filterProjectId = (int)matched.Id;
else if (string.IsNullOrWhiteSpace(projectSearchInput))
filterProjectId = 0;
currentPage = 1;
await LoadTaskReport();
}
private async Task ResetAllFilters()
{
searchInput = "";
searchQuery = "";
filterPeriod = "all";
filterStartDate = null;
filterEndDate = null;
filterStatus = "uncompleted";
filterProjectId = 0;
projectSearchInput = string.Empty;
projectSearchQuery = string.Empty;
showProjectSuggestions = false;
filterAssignToMe = false;
filterAssignToMyTeam = false;
currentPage = 1;
await LoadTaskReport();
}
private async Task HandlePageChanged(int page)
{
currentPage = page;
await LoadTaskReport();
}
private async Task HandlePageSizeChanged(int size)
{
pageSize = size;
currentPage = 1;
await LoadTaskReport();
}
private IEnumerable<TaskReportDto> FilteredList => taskReports;
private int TotalPages => taskReportPage?.TotalPages ?? 0;
private IEnumerable<TaskReportDto> PagedList => taskReportPage?.Items ?? Enumerable.Empty<TaskReportDto>();
private IEnumerable<string> TaskInsights => new[]
{
$"{FilteredList.Count(t => t.TaskHealth == "Critical")} {AppLocalization.Text("report.criticalTasksNeedAttention", "critical tasks need attention.")}",
$"{FilteredList.Sum(t => t.OpenIssues)} {AppLocalization.Text("report.openIssuesAttached", "open issues are attached to reported tasks.")}",
$"{FilteredList.Count(t => t.OverdueIssues > 0)} {AppLocalization.Text("report.tasksHaveOverdueIssues", "tasks have overdue issues.")}"
};
private int FilteredTaskCount => FilteredList.Count();
private int CompletedTaskCount => FilteredList.Count(t => t.StatusId == AppTaskStatus.Done);
private int OpenTaskCount => FilteredTaskCount - CompletedTaskCount;
private int HighPriorityCount => FilteredList.Count(t => t.PriorityId == TaskPriority.High);
private double CompletionRate => FilteredTaskCount > 0 ? CompletedTaskCount / (double)FilteredTaskCount * 100 : 0;
private string TaskStatusInsight => OpenTaskCount > CompletedTaskCount
? $"{OpenTaskCount} {AppLocalization.Text("report.tasksStillMoving", "tasks are still moving through execution.")}"
: AppLocalization.Text("report.completedWorkLeading", "Completed work is leading the current filtered set.");
private string PriorityInsight => HighPriorityCount > 0
? $"{HighPriorityCount} {AppLocalization.Text("report.highPriorityVisible", "high-priority tasks should stay visible during planning review.")}"
: AppLocalization.Text("report.noHighPriority", "No high-priority tasks in the current filter.");
private string CompletionInsight => CompletionRate >= 70
? AppLocalization.Text("report.completionHealthy", "Completion is healthy for the current filter.")
: AppLocalization.Text("report.completionImprove", "Completion has room to improve; inspect open issues and overdue work.");
private List<ReportChartItem> TaskStatusItems => new()
{
new(AppLocalization.StatusLabel(AppTaskStatus.Todo), FilteredList.Count(t => t.StatusId == AppTaskStatus.Todo), "#94a3b8"),
new(AppLocalization.StatusLabel(AppTaskStatus.InProgress), FilteredList.Count(t => t.StatusId == AppTaskStatus.InProgress), "#3b82f6"),
new(AppLocalization.StatusLabel(AppTaskStatus.Done), CompletedTaskCount, "#10b981"),
new(AppLocalization.Text("status.overdue", "Overdue"), FilteredList.Count(t => t.DueDate.Date < DateTime.Today && t.StatusId != AppTaskStatus.Done), "#ef4444")
};
private List<StackedSegment> PrioritySegments => new()
{
new(AppLocalization.PriorityLabel(TaskPriority.High), HighPriorityCount, "#ef4444"),
new(AppLocalization.PriorityLabel(TaskPriority.Medium), FilteredList.Count(t => t.PriorityId == TaskPriority.Medium), "#f59e0b"),
new(AppLocalization.PriorityLabel(TaskPriority.Low), FilteredList.Count(t => t.PriorityId == TaskPriority.Low), "#3b82f6")
};
private List<SparklinePoint> CompletionSparkline => FilteredList
.GroupBy(t => new DateTime(t.CreatedAt.Year, t.CreatedAt.Month, 1))
.OrderBy(g => g.Key)
.TakeLast(8)
.Select(g => new SparklinePoint(g.Key.ToString("MMM"), g.Count(t => t.StatusId == AppTaskStatus.Done)))
.DefaultIfEmpty(new SparklinePoint(DateTime.Today.ToString("MMM"), CompletedTaskCount))
.ToList();
private List<TopRiskItem> ProjectRiskItems => FilteredList
.GroupBy(t => t.ProjectName)
.Select(g =>
{
var score = Math.Min(100, (g.Count() * 8) + (g.Sum(t => t.OpenIssues) * 12) + (g.Sum(t => t.OverdueIssues) * 18) + (g.Count(t => t.TaskHealth == "Critical") * 20));
var overdue = g.Sum(t => t.OverdueIssues);
var tone = overdue > 0 ? "rose" : score >= 45 ? "amber" : "blue";
return new TopRiskItem(
g.Key,
$"{g.Count()} {AppLocalization.Text("common.task", "tasks")} · {g.Sum(t => t.OpenIssues)} {AppLocalization.Text("common.issue", "open issues")}",
score,
overdue > 0 ? $"{overdue} {AppLocalization.Text("status.overdue", "overdue")}" : $"{score:0} {AppLocalization.Text("report.risk", "risk")}",
tone,
$"{g.Count(t => t.PriorityId == TaskPriority.High)} {AppLocalization.Text("report.highPriorityTasks", "high-priority tasks")}");
})
.OrderByDescending(item => item.Score)
.Take(5)
.ToList();
private List<TopRiskItem> TaskIssueRiskItems => FilteredList
.OrderByDescending(t => (t.OpenIssues * 2) + (t.OverdueIssues * 3))
.Take(5)
.Select(t =>
{
var score = Math.Min(100, (t.OpenIssues * 20) + (t.OverdueIssues * 30));
var tone = t.OverdueIssues > 0 ? "rose" : t.OpenIssues > 0 ? "amber" : "emerald";
return new TopRiskItem(
t.Title,
t.ProjectName,
score,
$"{t.OpenIssues + t.OverdueIssues} {AppLocalization.Text("common.issue", "issues")}",
tone,
$"{AppLocalization.StatusLabel(t.StatusId)} · {AppLocalization.PriorityLabel(t.PriorityId)}");
})
.ToList();
private List<ProgressListChartItem> TasksByProjectItems => FilteredList
.GroupBy(t => t.ProjectName)
.OrderByDescending(g => g.Count())
.Take(5)
.Select(g => new ProgressListChartItem(g.Key, g.Count(), g.Count().ToString(), "#7c3aed"))
.ToList();
private double TasksByProjectMax => Math.Max(1, TasksByProjectItems.Select(item => item.Value).DefaultIfEmpty(0).Max());
private List<ProgressListChartItem> TasksWithMostIssuesItems => FilteredList
.OrderByDescending(t => t.OpenIssues + t.OverdueIssues)
.Take(5)
.Select(t => new ProgressListChartItem(t.Title, t.OpenIssues + t.OverdueIssues, (t.OpenIssues + t.OverdueIssues).ToString(), "#7c3aed"))
.ToList();
private double TasksWithMostIssuesMax => Math.Max(1, TasksWithMostIssuesItems.Select(item => item.Value).DefaultIfEmpty(0).Max());
protected override async Task OnInitializedAsync()
{
var client = ApiClient.CreateClient();
try
{
projects = await client.GetFromJsonAsync<List<ProjectDto>>("Project") ?? new();
}
catch { }
await LoadTaskReport();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JS.InvokeVoidAsync("initIcons");
}
private IEnumerable<ProjectDto> FilteredProjects =>
string.IsNullOrWhiteSpace(projectSearchQuery)
? projects
: projects.Where(p => p.Name.Contains(projectSearchQuery.Trim(), StringComparison.OrdinalIgnoreCase));
private void SetView(bool chartView)
{
isChartView = chartView;
}
private void ToggleExpanded(long taskId)
{
if (!expandedTasks.Remove(taskId))
{
expandedTasks.Add(taskId);
}
}
private void ShowProjectSuggestions()
{
projectSearchQuery = projectSearchInput;
showProjectSuggestions = true;
}
private async Task HideProjectSuggestionsDelayed()
{
await Task.Delay(150);
showProjectSuggestions = false;
await InvokeAsync(StateHasChanged);
}
private void SelectProject(ProjectDto project)
{
filterProjectId = (int)project.Id;
projectSearchInput = project.Name;
projectSearchQuery = project.Name;
showProjectSuggestions = false;
}
private void ClearProjectFilter()
{
filterProjectId = 0;
projectSearchInput = string.Empty;
projectSearchQuery = string.Empty;
showProjectSuggestions = false;
}
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 LoadTaskReport()
{
isLoading = true;
projectSearchQuery = projectSearchInput;
StateHasChanged();
var client = ApiClient.CreateClient();
try
{
errorMessage = null;
var query = BuildTaskReportQuery();
var fullTask = client.GetFromJsonAsync<Result<List<TaskReportDto>>>(query);
var pagedTask = client.GetFromJsonAsync<PagedResult<TaskReportDto>>(BuildTaskReportQuery(includePaging: true));
await System.Threading.Tasks.Task.WhenAll(fullTask, pagedTask);
if (fullTask.Result?.IsSuccess == true && fullTask.Result.Value != null)
{
taskReports = fullTask.Result.Value;
}
else if (fullTask.Result?.IsSuccess == false)
{
errorMessage = fullTask.Result.ErrorMessage ?? AppLocalization.Text("report.loadError", "The server returned an error while loading task report data.");
}
if (pagedTask.Result != null)
{
taskReportPage = pagedTask.Result;
currentPage = taskReportPage.Page;
pageSize = taskReportPage.PageSize;
}
}
catch (Exception ex)
{
Console.WriteLine($"Task report error: {ex.Message}");
errorMessage = ex.Message;
taskReports = new();
taskReportPage = null;
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private string BuildTaskReportQuery(bool includePaging = false)
{
var parts = new List<string>();
if (filterStartDate.HasValue) parts.Add($"startDate={filterStartDate:yyyy-MM-dd}");
if (filterEndDate.HasValue) parts.Add($"endDate={filterEndDate:yyyy-MM-dd}");
if (!string.IsNullOrEmpty(filterStatus)) parts.Add($"status={Uri.EscapeDataString(filterStatus)}");
if (filterProjectId > 0) parts.Add($"projectId={filterProjectId}");
if (filterAssignToMe) parts.Add("assignedToMe=true");
if (filterAssignToMyTeam) parts.Add("assignedToMyTeam=true");
if (includePaging)
{
parts.Add($"page={currentPage}");
parts.Add($"limit={pageSize}");
}
return "Report/tasks" + (parts.Count > 0 ? "?" + string.Join("&", parts) : "");
}
private async Task DownloadExcel()
{
var rows = FilteredList.Select(t => new object?[]
{
t.Title,
t.ProjectName,
AppLocalization.StatusLabel(t.StatusId),
AppLocalization.PriorityLabel(t.PriorityId),
t.AssignedToUser ?? AppLocalization.Text("common.na", "Unassigned"),
t.DueDate
});
var workbookBytes = SpreadsheetReportBuilder.BuildTableWorkbook(
AppLocalization.PageTitle("taskReport", "Task Report"),
AppLocalization.PageTitle("taskReport", "Task Report"),
new[] { AppLocalization.Text("common.task", "Task"), AppLocalization.Text("common.project", "Project"), AppLocalization.Text("common.status", "Status"), AppLocalization.Text("common.priority", "Priority"), AppLocalization.Text("common.assignee", "Assigned To"), AppLocalization.Text("common.dueDate", "Due Date") },
rows,
new[]
{
$"{AppLocalization.Text("common.search", "Search")}: {(string.IsNullOrWhiteSpace(searchQuery) ? AppLocalization.Text("common.all", "All tasks") : searchQuery)}",
$"{AppLocalization.Text("report.periodFilter", "Period")}: {GetPeriodLabel(filterPeriod)}",
$"{AppLocalization.Text("common.status", "Status")}: {filterStatus switch { "0" => AppLocalization.Text("common.all", "All"), "1" => AppLocalization.StatusLabel(AppTaskStatus.Todo), "2" => AppLocalization.StatusLabel(AppTaskStatus.InProgress), "3" => AppLocalization.StatusLabel(AppTaskStatus.Done), "overdue" => AppLocalization.Text("status.overdue", "Overdue"), "uncompleted" => AppLocalization.Text("status.uncompleted", "Uncompleted"), _ => filterStatus }}",
$"{AppLocalization.Text("common.project", "Project")}: {(filterProjectId > 0 ? projects.FirstOrDefault(p => p.Id == filterProjectId)?.Name ?? AppLocalization.Text("report.selectedProject", "Selected project") : AppLocalization.Text("common.all", "All projects"))}",
$"{AppLocalization.Text("common.date", "Generated")}: {DisplayFormats.Date(DateTime.Today)}"
});
await JS.InvokeVoidAsync(
"downloadFileFromBase64",
$"TaskReport_{DateTime.Today:yyyyMMdd}.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
Convert.ToBase64String(workbookBytes));
}
private async Task DownloadPdf()
{
var summaryLines = new List<string>
{
$"{AppLocalization.Text("common.search", "Search")}: {(string.IsNullOrWhiteSpace(searchQuery) ? AppLocalization.Text("common.all", "All tasks") : searchQuery)}",
$"{AppLocalization.Text("report.periodFilter", "Period")}: {GetPeriodLabel(filterPeriod)}",
$"{AppLocalization.Text("common.status", "Status")}: {filterStatus switch { "0" => AppLocalization.Text("common.all", "All"), "1" => AppLocalization.StatusLabel(AppTaskStatus.Todo), "2" => AppLocalization.StatusLabel(AppTaskStatus.InProgress), "3" => AppLocalization.StatusLabel(AppTaskStatus.Done), "overdue" => AppLocalization.Text("status.overdue", "Overdue"), "uncompleted" => AppLocalization.Text("status.uncompleted", "Uncompleted"), _ => filterStatus }}",
$"{AppLocalization.Text("common.project", "Project")}: {(filterProjectId > 0 ? projects.FirstOrDefault(p => p.Id == filterProjectId)?.Name ?? AppLocalization.Text("report.selectedProject", "Selected project") : AppLocalization.Text("common.all", "All projects"))}",
$"{AppLocalization.Text("common.date", "Generated")}: {DisplayFormats.Date(DateTime.Today)}"
};
var rows = FilteredList.Select(t => new[]
{
t.Title,
t.ProjectName,
AppLocalization.StatusLabel(t.StatusId),
AppLocalization.PriorityLabel(t.PriorityId),
t.AssignedToUser ?? AppLocalization.Text("common.na", "Unassigned"),
DisplayFormats.Date(t.DueDate)
});
var pdfBytes = SimplePdfReportBuilder.BuildTableReport(
AppLocalization.PageTitle("taskReport", "Task Report"),
summaryLines,
new[] { AppLocalization.Text("common.task", "Task"), AppLocalization.Text("common.project", "Project"), AppLocalization.Text("common.status", "Status"), AppLocalization.Text("common.priority", "Priority"), AppLocalization.Text("common.assignee", "Assigned To"), AppLocalization.Text("common.dueDate", "Due Date") },
rows);
await JS.InvokeVoidAsync(
"downloadFileFromBase64",
$"TaskReport_{DateTime.Today:yyyyMMdd}.pdf",
"application/pdf",
Convert.ToBase64String(pdfBytes));
}
private string GetStatusBadge(AppTaskStatus statusId) => statusId switch
{
AppTaskStatus.Todo => "bg-slate-100 text-slate-700",
AppTaskStatus.InProgress => "bg-blue-100 text-blue-700",
AppTaskStatus.Done => "bg-emerald-100 text-emerald-700",
_ => "bg-slate-100 text-slate-600"
};
private string GetPriorityBadge(TaskPriority priorityId) => priorityId switch
{
TaskPriority.Low => "bg-slate-100 text-slate-600",
TaskPriority.Medium => "bg-amber-100 text-amber-700",
TaskPriority.High => "bg-red-100 text-red-700",
_ => "bg-slate-100 text-slate-600"
};
private static string GetHealthLabel(string health) => health switch
{
"Critical" => AppLocalization.Text("report.healthCritical", "Critical"),
"At Risk" => AppLocalization.Text("report.healthAtRisk", "At Risk"),
_ => AppLocalization.Text("report.healthGood", "Healthy")
};
private static string GetHealthBadge(string health) => health switch
{
"Critical" => "bg-rose-50 text-rose-700",
"At Risk" => "bg-amber-50 text-amber-700",
_ => "bg-emerald-50 text-emerald-700"
};
private static string GetPeriodLabel(string period) => period switch
{
"daily" => AppLocalization.Text("report.daily", "Daily (Today)"),
"monthly" => AppLocalization.Text("report.monthly", "Monthly (This Month)"),
"yearly" => AppLocalization.Text("report.yearly", "Yearly (This Year)"),
"custom" => AppLocalization.Text("report.customRange", "Custom Range"),
_ => AppLocalization.Text("common.all", "All Time")
};
private RenderFragment TaskMetricCard(string label, string value, string icon, string color) => builder =>
{
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", $"rounded-xl border border-{color}-100 bg-{color}-50 p-5 shadow-sm");
builder.AddMarkupContent(2, $"<div class=\"flex items-center justify-between\"><div><p class=\"text-xs font-semibold uppercase tracking-wider text-{color}-600\">{label}</p><p class=\"mt-1 text-3xl font-bold text-{color}-700\">{value}</p></div><span class=\"flex h-11 w-11 items-center justify-center rounded-xl bg-white/70\"><i data-lucide=\"{icon}\" class=\"h-6 w-6 text-{color}-600\"></i></span></div>");
builder.CloseElement();
};
}