User
add: language switch
8c88a89
Raw
History Blame Contribute Delete
33.4 kB
@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
<PageTitle>@AppLocalization.PageTitle("issueReport", "Issue Report")</PageTitle>
<div class="space-y-6">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.PageTitle("issueReport", "Issue Report")</h2>
<p class="mt-1 text-slate-500">@AppLocalization.PageDescription("issueReport", "Track daily execution, backlog health, and issue-level effort.")</p>
</div>
<div class="flex flex-wrap items-center gap-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")">
<i data-lucide="list" class="mr-1 h-3.5 w-3.5"></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")">
<i data-lucide="bar-chart-3" class="mr-1 h-3.5 w-3.5"></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 shadow-sm hover:bg-slate-50">
<i data-lucide="download" class="mr-2 h-4 w-4"></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 shadow-sm hover:bg-slate-50">
<i data-lucide="file-text" class="mr-2 h-4 w-4"></i> @AppLocalization.Text("common.exportPdf", "Export PDF")
</button>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div class="flex flex-wrap items-end gap-3">
<div class="min-w-[220px] flex-1">
<label class="mb-1.5 block text-xs font-medium text-slate-500">@AppLocalization.Text("common.search", "Search")</label>
<input @bind="searchInput" type="text" placeholder="@AppLocalization.Text("common.searchIssues", "Search issue, task, project, 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" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-500">@AppLocalization.Text("common.project", "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">
<option value="0">@AppLocalization.Text("common.all", "All Projects")</option>
@foreach (var project in projects)
{
<option value="@project.Id">@project.Name</option>
}
</select>
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-500">@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">
<option value="0">@AppLocalization.Text("common.all", "All Statuses")</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>
<option value="blocked">@AppLocalization.Text("status.blocked", "Blocked")</option>
</select>
</div>
<button @onclick="ApplyFilters" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-violet-700">
<i data-lucide="search" class="mr-2 h-4 w-4"></i> @AppLocalization.Text("action.search", "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-700 shadow-sm hover:bg-slate-50">
<i data-lucide="rotate-ccw" class="mr-2 h-4 w-4"></i> @AppLocalization.Text("action.reset", "Reset")
</button>
</div>
</div>
@if (isLoading)
{
<LoadingSpinner Message="@AppLocalization.Text("report.loadingIssueReport", "Loading issue report...")" />
}
else
{
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
@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")
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<h3 class="text-sm font-semibold text-slate-900">@AppLocalization.Text("report.keyInsights", "Key Insights")</h3>
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
@foreach (var insight in Insights)
{
<div class="rounded-lg border border-slate-100 bg-slate-50 px-3 py-2 text-sm text-slate-700">@insight</div>
}
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
@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.issueLifecycleFunnel", "Issue lifecycle funnel")" Subtitle="@AppLocalization.Text("report.issueLifecycleFunnelDesc", "How issues move from backlog to resolution.")" Insight="@AppLocalization.Text("report.issueLifecycleInsight", "A large in-progress or blocked band is a daily execution signal.")">
<FunnelChart Items="IssueFunnelItems" />
</ReportChartCard>
</div>
<div class="xl:col-span-4">
<ReportChartCard Title="@AppLocalization.Text("report.issueStatusDonut", "Issue status donut")" Subtitle="@AppLocalization.Text("report.issueStatusDonutDesc", "Current status distribution, including overdue pressure.")" Insight="@IssueStatusInsight">
<DonutStatusChart Items="IssueStatusItems" CenterLabel="@AppLocalization.Text("common.issue", "issues")" />
</ReportChartCard>
</div>
<div class="xl:col-span-3">
<RadialMetricCard Title="@AppLocalization.Text("report.blockedOverdue", "Blocked / overdue")" Subtitle="@AppLocalization.Text("report.executionRisk", "Execution risk in the filtered issue set.")" Percent="@BlockedOverduePercent" Value="@((BlockedIssues + OverdueIssues).ToString())" ValueLabel="@AppLocalization.Text("report.riskIssues", "risk issues")" CenterLabel="@AppLocalization.Text("report.risk", "risk")" Color="#ef4444" Insight="@AppLocalization.Text("report.focusStandup", "Focus standup discussion here first.")" />
</div>
<div class="xl:col-span-4">
<ReportChartCard Title="@AppLocalization.Text("report.issuesByProject", "Issues by project")" Subtitle="@AppLocalization.Text("report.issuesByProjectDesc", "Where issue volume is concentrated.")" Insight="@AppLocalization.Text("report.issuesByProjectInsight", "Useful for project-level triage and planning health.")">
<TopRiskList Items="IssueProjectItems" EmptyText="@AppLocalization.Text("common.noResults", "No project issues found.")" />
</ReportChartCard>
</div>
<div class="xl:col-span-4">
<ReportChartCard Title="@AppLocalization.Text("report.issuesByAssignee", "Issues by assignee")" Subtitle="@AppLocalization.Text("report.issuesByAssigneeDesc", "Support needs by assignee.")" Insight="@AppLocalization.Text("report.issuesByAssigneeInsight", "Use as a support signal, especially when overdue or blocked items cluster.")">
<TopRiskList Items="IssueAssigneeItems" EmptyText="@AppLocalization.Text("common.noResults", "No assignee issues found.")" />
</ReportChartCard>
</div>
<div class="xl:col-span-4">
<ReportChartCard Title="@AppLocalization.Text("report.resolutionAging", "Resolution aging")" Subtitle="@AppLocalization.Text("report.resolutionAgingDesc", "How close issues are to or past due date.")" Insight="@AppLocalization.Text("report.resolutionAgingInsight", "Older overdue buckets should be cleared before new scope expands.")">
<AgingBucketChart Buckets="IssueAgingBuckets" />
</ReportChartCard>
</div>
</div>
}
else
{
<div class="overflow-x-hidden">
<table class="w-full table-fixed">
<colgroup>
<col class="w-[27%]" />
<col class="w-[17%]" />
<col class="w-[18%]" />
<col class="w-[11%]" />
<col class="w-[10%]" />
<col class="w-[9%]" />
<col class="w-[8%]" />
</colgroup>
<thead>
<tr class="border-b border-slate-100 bg-slate-50/60">
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">@AppLocalization.Text("common.issue", "Issue")</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">@AppLocalization.Text("common.taskProject", "Task / Project")</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">@AppLocalization.Text("common.assignee", "Assignee")</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">@AppLocalization.Text("common.status", "Status")</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">@AppLocalization.Text("common.hours", "Hours")</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">@AppLocalization.Text("common.dueDate", "Due")</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">@AppLocalization.Text("common.risk", "Risk")</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@foreach (var issue in PagedIssues)
{
<tr class="align-top hover:bg-slate-50/50">
<td class="px-4 py-3">
<button @onclick="() => ToggleExpanded(issue.Id)" class="block w-full text-left text-sm font-semibold text-slate-900 hover:text-violet-600 break-words">@issue.Title</button>
@if (expandedIssues.Contains(issue.Id))
{
<div class="mt-2 rounded-lg bg-slate-50 p-3 text-xs text-slate-600">
<div>@(string.IsNullOrWhiteSpace(issue.Description) ? AppLocalization.Text("common.noDescription", "No description") : issue.Description)</div>
<div class="mt-1">@AppLocalization.Text("report.delay", "Delay"): @(string.IsNullOrWhiteSpace(issue.DelayReason) ? AppLocalization.Text("common.noneRecorded", "None recorded") : issue.DelayReason)</div>
<div>@AppLocalization.Text("report.blockedBy", "Blocked by"): @(string.IsNullOrWhiteSpace(issue.BlockedBy) ? AppLocalization.Text("common.none", "None") : issue.BlockedBy)</div>
</div>
}
</td>
<td class="px-4 py-3 text-sm text-slate-600">
<button @onclick="() => ToggleTaskDetails(issue.Id)" class="inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 font-mono text-[11px] font-semibold tracking-wide text-indigo-700 hover:border-indigo-200 hover:bg-indigo-50">
@GetTaskCode(issue.TaskId)
</button>
@if (expandedTaskDetails.Contains(issue.Id))
{
<div class="mt-2 rounded-lg bg-slate-50 p-3 text-xs text-slate-600">
<div class="font-medium text-slate-800 break-words">@issue.TaskTitle</div>
<div class="mt-1 break-words">@issue.ProjectName</div>
</div>
}
</td>
<td class="px-4 py-3 text-sm text-slate-600 break-words">@(issue.AssignedToName ?? AppLocalization.Text("common.unassigned", "Unassigned"))</td>
<td class="px-4 py-3"><span class="inline-flex whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-medium @GetStatusBadge(issue.StatusId)">@GetStatusLabel(issue.StatusId)</span></td>
<td class="px-4 py-3 text-sm text-slate-700 whitespace-nowrap">@((issue.EstimatedHours ?? 0).ToString("N1")) / @((issue.ActualHours ?? 0).ToString("N1"))</td>
<td class="px-4 py-3 text-sm whitespace-nowrap @(IsOverdue(issue) ? "font-semibold text-rose-600" : "text-slate-600")">@DisplayFormats.Date(issue.DueDate)</td>
<td class="px-4 py-3 whitespace-nowrap">@RiskBadge(issue)</td>
</tr>
}
@if (!PagedIssues.Any())
{
<EmptyState ColSpan="7" Icon="file-pen-line" Title="@AppLocalization.Text("report.noIssuesFound", "No issues found")" Subtitle="@AppLocalization.Text("report.noIssuesFoundDesc", "No issues matched the current report filters.")" />
}
</tbody>
</table>
</div>
<Pagination CurrentPage="currentPage" TotalPages="TotalPages" PageSize="pageSize" TotalCount="FilteredIssues.Count()" OnPageChanged="HandlePageChanged" OnPageSizeChanged="HandlePageSizeChanged" />
}
</div>
}
</div>
@code {
private bool isLoading = true;
private bool isChartView;
private List<IssueDto> issues = new();
private List<ProjectDto> projects = new();
private HashSet<long> expandedIssues = new();
private HashSet<long> 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<IssueDto> FilteredIssues => issues.Where(MatchesFilters).OrderBy(i => i.DueDate).ThenBy(i => i.Title);
private IEnumerable<IssueDto> 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<Result<List<IssueDto>>>("Report/issues", Serialization.CaseInsensitive);
var projectsTask = client.GetFromJsonAsync<List<ProjectDto>>("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<string?>("localStorage.getItem", StorageKey);
if (string.IsNullOrWhiteSpace(json)) return;
var saved = System.Text.Json.JsonSerializer.Deserialize<SavedFilter>(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<TaskPriority>().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<string> 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<ReportChartItem> 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<ReportChartItem> 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<TopRiskItem> 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<TopRiskItem> 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<ReportChartItem> 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, $"<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();
};
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);
}