Spaces:
Running
Running
task-tracking-system / TaskTrackingSystem.WebApp /Components /Pages /Features /Reports /TaskReport.razor
| @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(); | |
| }; | |
| } | |