File size: 33,359 Bytes
4671199
 
 
 
 
 
 
 
 
 
 
8c88a89
4671199
 
 
 
8c88a89
 
4671199
 
 
 
8c88a89
4671199
 
8c88a89
4671199
 
 
8c88a89
4671199
 
8c88a89
4671199
 
 
 
 
 
 
8c88a89
 
4671199
 
 
8c88a89
4671199
8c88a89
4671199
 
 
 
 
 
 
8c88a89
4671199
8c88a89
 
 
 
 
 
4671199
 
 
8c88a89
4671199
 
8c88a89
4671199
 
 
 
 
 
8c88a89
4671199
 
 
 
8c88a89
 
 
 
4671199
 
 
8c88a89
4671199
 
 
 
 
 
 
 
 
 
 
3b3dce7
 
8c88a89
3b3dce7
 
 
 
8c88a89
 
3b3dce7
 
 
8c88a89
3b3dce7
 
8c88a89
 
3b3dce7
 
 
8c88a89
 
3b3dce7
 
 
8c88a89
3b3dce7
 
 
4671199
 
 
 
2358ba3
 
 
 
 
 
 
 
 
 
 
4671199
 
8c88a89
 
 
 
 
 
 
4671199
 
 
 
 
 
 
2358ba3
4671199
 
 
8c88a89
 
 
4671199
 
 
2358ba3
 
 
 
 
 
8c88a89
 
 
 
2358ba3
 
8c88a89
2358ba3
 
 
 
4671199
 
 
 
8c88a89
4671199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2358ba3
4671199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2358ba3
4671199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2358ba3
8c88a89
4671199
8c88a89
 
4671199
 
 
8c88a89
4671199
 
 
 
 
 
 
8c88a89
4671199
8c88a89
4671199
8c88a89
3b3dce7
 
8c88a89
 
4671199
3b3dce7
4671199
8c88a89
 
 
 
 
4671199
 
3b3dce7
4671199
8c88a89
 
 
 
3b3dce7
 
 
 
 
4671199
3b3dce7
 
 
8c88a89
3b3dce7
 
 
 
 
 
8c88a89
3b3dce7
 
 
 
 
8c88a89
3b3dce7
 
 
 
 
 
 
8c88a89
 
 
 
3b3dce7
 
 
 
 
 
 
4671199
 
 
 
 
8c88a89
 
4671199
 
 
 
 
8c88a89
 
4671199
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
@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);
}