@page "/reports/issues" @using Microsoft.AspNetCore.Authorization @using TaskTrackingSystem.Shared @using TaskTrackingSystem.Shared.Enums @using TaskTrackingSystem.Shared.Models.Issue @using TaskTrackingSystem.Shared.Models.Project @rendermode @(new InteractiveServerRenderMode(prerender: false)) @attribute [Authorize] @inject ApiClientService ApiClient @inject IJSRuntime JS @AppLocalization.PageTitle("issueReport", "Issue Report")

@AppLocalization.PageTitle("issueReport", "Issue Report")

@AppLocalization.PageDescription("issueReport", "Track daily execution, backlog health, and issue-level effort.")

@if (isLoading) { } else {
@MetricCard(AppLocalization.Text("report.totalIssues", "Total Issues"), FilteredIssues.Count().ToString(), "file-pen-line", "violet") @MetricCard(AppLocalization.Text("report.openIssues", "Open Issues"), OpenIssues.ToString(), "list-todo", "blue") @MetricCard(AppLocalization.Text("report.overdueIssues", "Overdue Issues"), OverdueIssues.ToString(), "alarm-clock", "rose") @MetricCard(AppLocalization.Text("report.blockedIssues", "Blocked Issues"), BlockedIssues.ToString(), "ban", "amber")

@AppLocalization.Text("report.keyInsights", "Key Insights")

@foreach (var insight in Insights) {
@insight
}
@if (isChartView) {
} else {
@foreach (var issue in PagedIssues) { } @if (!PagedIssues.Any()) { }
@AppLocalization.Text("common.issue", "Issue") @AppLocalization.Text("common.taskProject", "Task / Project") @AppLocalization.Text("common.assignee", "Assignee") @AppLocalization.Text("common.status", "Status") @AppLocalization.Text("common.hours", "Hours") @AppLocalization.Text("common.dueDate", "Due") @AppLocalization.Text("common.risk", "Risk")
@if (expandedIssues.Contains(issue.Id)) {
@(string.IsNullOrWhiteSpace(issue.Description) ? AppLocalization.Text("common.noDescription", "No description") : issue.Description)
@AppLocalization.Text("report.delay", "Delay"): @(string.IsNullOrWhiteSpace(issue.DelayReason) ? AppLocalization.Text("common.noneRecorded", "None recorded") : issue.DelayReason)
@AppLocalization.Text("report.blockedBy", "Blocked by"): @(string.IsNullOrWhiteSpace(issue.BlockedBy) ? AppLocalization.Text("common.none", "None") : issue.BlockedBy)
}
@if (expandedTaskDetails.Contains(issue.Id)) {
@issue.TaskTitle
@issue.ProjectName
}
@(issue.AssignedToName ?? AppLocalization.Text("common.unassigned", "Unassigned")) @GetStatusLabel(issue.StatusId) @((issue.EstimatedHours ?? 0).ToString("N1")) / @((issue.ActualHours ?? 0).ToString("N1")) @DisplayFormats.Date(issue.DueDate) @RiskBadge(issue)
}
}
@code { private bool isLoading = true; private bool isChartView; private List issues = new(); private List projects = new(); private HashSet expandedIssues = new(); private HashSet expandedTaskDetails = new(); private string searchInput = string.Empty; private string appliedSearch = string.Empty; private long filterProjectId; private long appliedProjectId; private string filterStatus = "0"; private string appliedStatus = "0"; private int currentPage = 1; private int pageSize = 10; private const string StorageKey = "tts.report.issues.filters"; private IEnumerable FilteredIssues => issues.Where(MatchesFilters).OrderBy(i => i.DueDate).ThenBy(i => i.Title); private IEnumerable PagedIssues => FilteredIssues.Skip((currentPage - 1) * pageSize).Take(pageSize); private int TotalPages => (int)Math.Ceiling((double)FilteredIssues.Count() / pageSize); private int OpenIssues => FilteredIssues.Count(i => i.StatusId != AppTaskStatus.Done); private int OverdueIssues => FilteredIssues.Count(IsOverdue); private int BlockedIssues => FilteredIssues.Count(i => i.IsBlocked); protected override async Task OnInitializedAsync() { var client = ApiClient.CreateClient(); try { var issuesTask = client.GetFromJsonAsync>>("Report/issues", Serialization.CaseInsensitive); var projectsTask = client.GetFromJsonAsync>("Project", Serialization.CaseInsensitive); await Task.WhenAll(issuesTask, projectsTask); issues = issuesTask.Result?.Value ?? new(); projects = projectsTask.Result ?? new(); } finally { isLoading = false; } } protected override async Task OnAfterRenderAsync(bool firstRender) { await JS.InvokeVoidAsync("initIcons"); if (firstRender) { await LoadSavedFilters(); } } private bool MatchesFilters(IssueDto issue) { if (appliedProjectId > 0 && issue.ProjectId != appliedProjectId) return false; if (appliedStatus == "overdue" && !IsOverdue(issue)) return false; if (appliedStatus == "blocked" && !issue.IsBlocked) return false; if (int.TryParse(appliedStatus, out var statusValue) && statusValue > 0 && (int)issue.StatusId != statusValue) return false; if (!string.IsNullOrWhiteSpace(appliedSearch)) { var s = appliedSearch.Trim(); return issue.Title.Contains(s, StringComparison.OrdinalIgnoreCase) || issue.TaskTitle.Contains(s, StringComparison.OrdinalIgnoreCase) || issue.ProjectName.Contains(s, StringComparison.OrdinalIgnoreCase) || (issue.AssignedToName?.Contains(s, StringComparison.OrdinalIgnoreCase) == true); } return true; } private async Task ApplyFilters() { appliedSearch = searchInput; appliedProjectId = filterProjectId; appliedStatus = filterStatus; currentPage = 1; await SaveFilters(); } private async Task ResetFilters() { searchInput = appliedSearch = string.Empty; filterProjectId = appliedProjectId = 0; filterStatus = appliedStatus = "0"; currentPage = 1; await SaveFilters(); } private void SetView(bool chartView) => isChartView = chartView; private void ToggleExpanded(long id) { if (!expandedIssues.Remove(id)) expandedIssues.Add(id); } private void ToggleTaskDetails(long id) { if (!expandedTaskDetails.Remove(id)) expandedTaskDetails.Add(id); } 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 SaveFilters() { await JS.InvokeVoidAsync("localStorage.setItem", StorageKey, System.Text.Json.JsonSerializer.Serialize(new SavedFilter(appliedSearch, appliedProjectId, appliedStatus))); } private async Task LoadSavedFilters() { var json = await JS.InvokeAsync("localStorage.getItem", StorageKey); if (string.IsNullOrWhiteSpace(json)) return; var saved = System.Text.Json.JsonSerializer.Deserialize(json); if (saved == null) return; searchInput = appliedSearch = saved.Search; filterProjectId = appliedProjectId = saved.ProjectId; filterStatus = appliedStatus = saved.Status; StateHasChanged(); } private static bool IsOverdue(IssueDto issue) => issue.StatusId != AppTaskStatus.Done && issue.DueDate.Date < DateTime.Today; private static string GetTaskCode(long taskId) => $"TSK-{taskId:D4}"; private static string GetStatusLabel(AppTaskStatus status) => AppLocalization.StatusLabel(status); private static string GetStatusBadge(AppTaskStatus status) => status == AppTaskStatus.Done ? "bg-emerald-50 text-emerald-700" : status == AppTaskStatus.InProgress ? "bg-blue-50 text-blue-700" : "bg-slate-100 text-slate-700"; private static string GetPriorityLabel(TaskPriority priority) => AppLocalization.PriorityLabel(priority); private static string EscalationLabel(int level) => level switch { 1 => AppLocalization.Text("report.teamLead", "Team Lead"), 2 => AppLocalization.Text("report.pm", "PM"), 3 => AppLocalization.Text("report.manager", "Manager"), _ => AppLocalization.Text("common.none", "None") }; private RenderFragment RiskBadge(IssueDto issue) => builder => { var text = issue.IsBlocked ? AppLocalization.Text("status.blocked", "Blocked") : issue.EscalationLevel > 0 ? EscalationLabel(issue.EscalationLevel) : IsOverdue(issue) ? AppLocalization.Text("status.overdue", "Overdue") : AppLocalization.Text("status.clear", "Clear"); var cls = issue.IsBlocked || IsOverdue(issue) ? "bg-rose-50 text-rose-700" : issue.EscalationLevel > 0 ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700"; builder.OpenElement(0, "span"); builder.AddAttribute(1, "class", $"rounded-full px-2 py-0.5 text-xs font-semibold {cls}"); builder.AddContent(2, text); builder.CloseElement(); }; private IReadOnlyList<(string Label, int Value)> StatusChart => new[] { (AppLocalization.StatusLabel(AppTaskStatus.Todo), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Todo)), (AppLocalization.StatusLabel(AppTaskStatus.InProgress), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.InProgress)), (AppLocalization.StatusLabel(AppTaskStatus.Done), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Done)), (AppLocalization.Text("status.overdue", "Overdue"), OverdueIssues) }; private IReadOnlyList<(string Label, int Value)> PriorityChart => Enum.GetValues().Select(p => (GetPriorityLabel(p), FilteredIssues.Count(i => i.PriorityId == p))).ToList(); private IReadOnlyList<(string Label, int Value)> EmployeeChart => FilteredIssues.GroupBy(i => i.AssignedToName ?? AppLocalization.Text("common.unassigned", "Unassigned")).OrderByDescending(g => g.Count()).Take(5).Select(g => (g.Key, g.Count())).ToList(); private IReadOnlyList<(string Label, int Value)> ProjectChart => FilteredIssues.GroupBy(i => i.ProjectName).OrderByDescending(g => g.Count()).Take(5).Select(g => (g.Key, g.Count())).ToList(); private IEnumerable Insights => new[] { $"{OverdueIssues} {AppLocalization.Text("report.overdueIssuesNeedAttention", "overdue issues need attention.")}", $"{BlockedIssues} {AppLocalization.Text("report.issuesAreBlocked", "issues are blocked.")}", $"{AppLocalization.Text("report.actualHoursAre", "Actual hours are")} {FilteredIssues.Sum(i => i.ActualHours ?? 0):N1} {AppLocalization.Text("report.against", "against")} {FilteredIssues.Sum(i => i.EstimatedHours ?? 0):N1} {AppLocalization.Text("report.estimated", "estimated.")}" }; private double BlockedOverduePercent => FilteredIssues.Any() ? (BlockedIssues + OverdueIssues) / (double)FilteredIssues.Count() * 100 : 0; private string IssueStatusInsight => OpenIssues > FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Done) ? $"{OpenIssues} {AppLocalization.Text("report.issuesStillOpen", "issues are still open across the selected scope.")}" : AppLocalization.Text("report.resolvedIssuesLeading", "Resolved issues are leading this filtered set."); private List IssueFunnelItems => new() { new(AppLocalization.Text("report.created", "Created"), FilteredIssues.Count(), "#7c3aed", AppLocalization.Text("report.allReportedIssues", "All reported issues")), new(AppLocalization.Text("report.open", "Open"), OpenIssues, "#3b82f6", AppLocalization.Text("report.unresolvedIssues", "Unresolved issues")), new(AppLocalization.StatusLabel(AppTaskStatus.InProgress), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.InProgress), "#06b6d4", AppLocalization.Text("report.currentlyBeingWorked", "Currently being worked")), new(AppLocalization.Text("status.blocked", "Blocked"), BlockedIssues, "#f59e0b", AppLocalization.Text("report.needsUnblockReview", "Needs unblock review")), new(AppLocalization.Text("status.resolved", "Resolved"), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Done), "#10b981", AppLocalization.Text("report.completedIssues", "Completed issues")) }; private List IssueStatusItems => new() { new(AppLocalization.StatusLabel(AppTaskStatus.Todo), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Todo), "#94a3b8"), new(AppLocalization.StatusLabel(AppTaskStatus.InProgress), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.InProgress), "#3b82f6"), new(AppLocalization.StatusLabel(AppTaskStatus.Done), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Done), "#10b981"), new(AppLocalization.Text("status.overdue", "Overdue"), OverdueIssues, "#ef4444") }; private List IssueProjectItems => FilteredIssues .GroupBy(i => i.ProjectName) .Select(g => { var blocked = g.Count(i => i.IsBlocked); var overdue = g.Count(IsOverdue); var score = Math.Min(100, (g.Count() * 10) + (blocked * 25) + (overdue * 20)); return new TopRiskItem(g.Key, $"{g.Count()} {AppLocalization.Text("common.issue", "issues")} ยท {blocked} {AppLocalization.Text("status.blocked", "blocked")}", score, overdue > 0 ? $"{overdue} {AppLocalization.Text("status.overdue", "overdue")}" : $"{g.Count()} {AppLocalization.Text("common.issue", "issues")}", overdue > 0 || blocked > 0 ? "rose" : "blue"); }) .OrderByDescending(item => item.Score) .Take(5) .ToList(); private List IssueAssigneeItems => FilteredIssues .GroupBy(i => i.AssignedToName ?? AppLocalization.Text("common.unassigned", "Unassigned")) .Select(g => { var blocked = g.Count(i => i.IsBlocked); var overdue = g.Count(IsOverdue); var score = Math.Min(100, (g.Count() * 12) + (blocked * 22) + (overdue * 18)); return new TopRiskItem(g.Key, $"{g.Count()} {AppLocalization.Text("report.assignedIssues", "assigned issues")}", score, blocked > 0 ? $"{blocked} {AppLocalization.Text("status.blocked", "blocked")}" : $"{overdue} {AppLocalization.Text("status.overdue", "overdue")}", blocked > 0 || overdue > 0 ? "amber" : "blue", AppLocalization.Text("report.supportSignal", "Support signal")); }) .OrderByDescending(item => item.Score) .Take(5) .ToList(); private List IssueAgingBuckets => new() { new(AppLocalization.Text("report.dueLater", "Due later"), FilteredIssues.Count(i => !IsOverdue(i) && i.DueDate.Date > DateTime.Today.AddDays(3)), "#3b82f6"), new(AppLocalization.Text("report.dueSoon", "Due soon"), FilteredIssues.Count(i => !IsOverdue(i) && i.DueDate.Date <= DateTime.Today.AddDays(3)), "#f59e0b"), new(AppLocalization.Text("report.1to7Late", "1-7d late"), FilteredIssues.Count(i => IsOverdue(i) && (DateTime.Today - i.DueDate.Date).Days <= 7), "#fb7185"), new(AppLocalization.Text("report.8PlusLate", "8d+ late"), FilteredIssues.Count(i => IsOverdue(i) && (DateTime.Today - i.DueDate.Date).Days > 7), "#ef4444") }; private RenderFragment MetricCard(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, $"

{label}

{value}

"); builder.CloseElement(); }; private async Task DownloadExcel() { var rows = FilteredIssues.Select(i => new object?[] { i.Title, i.TaskTitle, i.ProjectName, i.AssignedToName ?? AppLocalization.Text("common.unassigned", "Unassigned"), GetStatusLabel(i.StatusId), GetPriorityLabel(i.PriorityId), i.EstimatedHours ?? 0, i.ActualHours ?? 0, i.DueDate, i.IsBlocked ? AppLocalization.Text("status.blocked", "Blocked") : AppLocalization.Text("status.clear", "Clear"), EscalationLabel(i.EscalationLevel), i.DelayReason ?? "" }); var bytes = SpreadsheetReportBuilder.BuildTableWorkbook(AppLocalization.PageTitle("issueReport", "Issue Report"), AppLocalization.PageTitle("issueReport", "Issue Report"), new[] { AppLocalization.Text("common.issue", "Issue"), AppLocalization.Text("common.task", "Task"), AppLocalization.Text("common.project", "Project"), AppLocalization.Text("common.assignee", "Assignee"), AppLocalization.Text("common.status", "Status"), AppLocalization.Text("common.priority", "Priority"), AppLocalization.Text("report.estimated", "Estimated"), AppLocalization.Text("report.actual", "Actual"), AppLocalization.Text("common.dueDate", "Due Date"), AppLocalization.Text("status.blocked", "Blocked"), AppLocalization.Text("report.escalation", "Escalation"), AppLocalization.Text("report.delayReason", "Delay Reason") }, rows, new[] { $"{AppLocalization.Text("common.date", "Generated")}: {DisplayFormats.Date(DateTime.Today)}" }); await JS.InvokeVoidAsync("downloadFileFromBase64", $"IssueReport_{DateTime.Today:yyyyMMdd}.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", Convert.ToBase64String(bytes)); } private async Task DownloadPdf() { var rows = FilteredIssues.Select(i => new[] { i.Title, i.TaskTitle, i.ProjectName, i.AssignedToName ?? AppLocalization.Text("common.unassigned", "Unassigned"), GetStatusLabel(i.StatusId), DisplayFormats.Date(i.DueDate), i.IsBlocked ? AppLocalization.Text("status.blocked", "Blocked") : AppLocalization.Text("status.clear", "Clear") }); var bytes = SimplePdfReportBuilder.BuildTableReport(AppLocalization.PageTitle("issueReport", "Issue Report"), new[] { $"{AppLocalization.Text("common.date", "Generated")}: {DisplayFormats.Date(DateTime.Today)}" }, new[] { AppLocalization.Text("common.issue", "Issue"), AppLocalization.Text("common.task", "Task"), AppLocalization.Text("common.project", "Project"), AppLocalization.Text("common.assignee", "Assignee"), AppLocalization.Text("common.status", "Status"), AppLocalization.Text("common.dueDate", "Due Date"), AppLocalization.Text("common.issue", "Risk") }, rows); await JS.InvokeVoidAsync("downloadFileFromBase64", $"IssueReport_{DateTime.Today:yyyyMMdd}.pdf", "application/pdf", Convert.ToBase64String(bytes)); } private sealed record SavedFilter(string Search, long ProjectId, string Status); }