User commited on
Commit
3b3dce7
·
1 Parent(s): 917b070

update: Adjust all chart views on reports

Browse files
Files changed (18) hide show
  1. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/AgingBucketChart.razor +32 -0
  2. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/DonutStatusChart.razor +64 -0
  3. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/FunnelChart.razor +64 -0
  4. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/MetricComparisonCard.razor +26 -0
  5. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/RadialMetricCard.razor +37 -0
  6. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportChartCard.razor +43 -0
  7. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportChartModels.cs +13 -0
  8. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/RiskMatrixCard.razor +129 -0
  9. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/StackedProgressBar.razor +36 -0
  10. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/TopRiskList.razor +52 -0
  11. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/TrendSparklineCard.razor +50 -0
  12. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/WorkloadHeatmap.razor +55 -0
  13. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/EmployeeReport.razor +73 -15
  14. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/IssueReport.razor +84 -20
  15. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/OverdueTasksReport.razor +95 -2
  16. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/ProjectProgressReport.razor +158 -19
  17. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TaskReport.razor +101 -104
  18. TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TimeTrackingReport.razor +91 -21
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/AgingBucketChart.razor ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
2
+ @if (!Buckets.Any())
3
+ {
4
+ <div class="col-span-full rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@EmptyText</div>
5
+ }
6
+ else
7
+ {
8
+ @foreach (var bucket in Buckets)
9
+ {
10
+ var height = MaxValue > 0 ? Math.Clamp(bucket.Value / MaxValue * 100, 8, 100) : 8;
11
+ <div class="rounded-xl border border-slate-100 bg-slate-50 p-3">
12
+ <div class="flex h-28 items-end rounded-lg bg-white p-2">
13
+ <div class="w-full rounded-md" style="height: @height%; background-color: @bucket.Color;"></div>
14
+ </div>
15
+ <div class="mt-3 flex items-center justify-between gap-2">
16
+ <span class="text-xs font-semibold text-slate-700">@bucket.Label</span>
17
+ <span class="text-lg font-bold tabular-nums text-slate-950">@bucket.Value.ToString("0")</span>
18
+ </div>
19
+ @if (!string.IsNullOrWhiteSpace(bucket.Detail))
20
+ {
21
+ <p class="mt-1 text-[11px] text-slate-500">@bucket.Detail</p>
22
+ }
23
+ </div>
24
+ }
25
+ }
26
+ </div>
27
+
28
+ @code {
29
+ [Parameter] public IReadOnlyList<ReportChartItem> Buckets { get; set; } = Array.Empty<ReportChartItem>();
30
+ [Parameter] public string EmptyText { get; set; } = "No aging data available.";
31
+ private double MaxValue => Math.Max(1, Buckets.Select(bucket => bucket.Value).DefaultIfEmpty(0).Max());
32
+ }
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/DonutStatusChart.razor ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="grid gap-4 sm:grid-cols-[180px_minmax(0,1fr)] sm:items-center">
2
+ @if (Total <= 0)
3
+ {
4
+ <div class="col-span-full rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@EmptyText</div>
5
+ }
6
+ else
7
+ {
8
+ <div class="relative mx-auto h-40 w-40">
9
+ <svg viewBox="0 0 180 180" class="h-40 w-40 -rotate-90" role="img" aria-label="@AriaLabel">
10
+ <circle cx="90" cy="90" r="66" fill="none" stroke="#e2e8f0" stroke-width="22" />
11
+ @foreach (var slice in Slices)
12
+ {
13
+ <circle cx="90" cy="90" r="66" fill="none" stroke="@slice.Color" stroke-width="22" stroke-dasharray="@slice.DashArray" stroke-dashoffset="@slice.DashOffset" stroke-linecap="round" />
14
+ }
15
+ </svg>
16
+ <div class="absolute inset-0 flex flex-col items-center justify-center text-center">
17
+ <span class="text-2xl font-bold tabular-nums text-slate-950">@Total.ToString("0")</span>
18
+ <span class="text-[11px] font-semibold uppercase tracking-wider text-slate-400">@CenterLabel</span>
19
+ </div>
20
+ </div>
21
+ <div class="space-y-2">
22
+ @foreach (var item in Items)
23
+ {
24
+ var pct = Total > 0 ? item.Value / Total * 100 : 0;
25
+ <div class="flex items-center justify-between gap-3 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2">
26
+ <div class="flex min-w-0 items-center gap-2">
27
+ <span class="h-2.5 w-2.5 shrink-0 rounded-full" style="background-color: @item.Color"></span>
28
+ <span class="truncate text-xs font-medium text-slate-700">@item.Label</span>
29
+ </div>
30
+ <span class="shrink-0 text-xs font-semibold tabular-nums text-slate-900">@item.Value.ToString("0") · @pct.ToString("0")%</span>
31
+ </div>
32
+ }
33
+ </div>
34
+ }
35
+ </div>
36
+
37
+ @code {
38
+ [Parameter] public IReadOnlyList<ReportChartItem> Items { get; set; } = Array.Empty<ReportChartItem>();
39
+ [Parameter] public string CenterLabel { get; set; } = "total";
40
+ [Parameter] public string EmptyText { get; set; } = "No data available.";
41
+ [Parameter] public string AriaLabel { get; set; } = "Status distribution";
42
+
43
+ private double Total => Items.Sum(item => Math.Max(0, item.Value));
44
+ private IReadOnlyList<Slice> Slices => BuildSlices();
45
+
46
+ private IReadOnlyList<Slice> BuildSlices()
47
+ {
48
+ const double radius = 66;
49
+ var circumference = 2 * Math.PI * radius;
50
+ var offset = 0.0;
51
+ var slices = new List<Slice>();
52
+
53
+ foreach (var item in Items)
54
+ {
55
+ var arc = Total > 0 ? circumference * Math.Max(0, item.Value) / Total : 0;
56
+ slices.Add(new Slice(item.Color, $"{arc:0.##} {(circumference - arc):0.##}", -offset));
57
+ offset += arc;
58
+ }
59
+
60
+ return slices;
61
+ }
62
+
63
+ private sealed record Slice(string Color, string DashArray, double DashOffset);
64
+ }
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/FunnelChart.razor ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="space-y-3">
2
+ @if (!Items.Any())
3
+ {
4
+ <div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@EmptyText</div>
5
+ }
6
+ else
7
+ {
8
+ @foreach (var item in Items.Select((value, index) => new { value, index }))
9
+ {
10
+ var stage = item.value;
11
+ var percent = Baseline > 0 ? stage.Value / Baseline * 100 : 0;
12
+ var width = stage.Value <= 0 ? 0 : Math.Clamp(percent, MinVisiblePercent, 100);
13
+ <div class="rounded-lg border border-transparent p-2 transition-colors hover:border-slate-100 hover:bg-slate-50" title="@BuildTitle(stage, percent)">
14
+ <div class="mb-1.5 grid grid-cols-[96px_1fr_72px] items-center gap-3 text-xs">
15
+ <span class="truncate font-semibold text-slate-700">@stage.Label</span>
16
+ <span class="text-[11px] text-slate-400">@GetStageHelper(stage)</span>
17
+ <span class="text-right font-bold tabular-nums text-slate-950">@stage.Value.ToString("0") · @percent.ToString("0")%</span>
18
+ </div>
19
+ <div class="h-8 rounded-lg bg-slate-100 p-1">
20
+ <div class="@GetFillClass(stage)" style="width: @width%; background-color: @(stage.Value > 0 ? stage.Color : "transparent");">
21
+ @if (stage.Value > 0)
22
+ {
23
+ <span class="truncate px-2">@stage.Label</span>
24
+ }
25
+ </div>
26
+ </div>
27
+ </div>
28
+ }
29
+
30
+ <div class="rounded-lg border border-slate-100 bg-slate-50 px-3 py-2 text-xs text-slate-500">
31
+ Width is measured against @Items.First().Label.ToLowerInvariant() (@Baseline.ToString("0")). Zero-value stages keep an empty track with no colored fill.
32
+ </div>
33
+ }
34
+ </div>
35
+
36
+ @code {
37
+ [Parameter] public IReadOnlyList<ReportChartItem> Items { get; set; } = Array.Empty<ReportChartItem>();
38
+ [Parameter] public string EmptyText { get; set; } = "No funnel data available.";
39
+ [Parameter] public double MinVisiblePercent { get; set; } = 4;
40
+
41
+ private double Baseline => Math.Max(1, Items.FirstOrDefault()?.Value ?? Items.Select(item => item.Value).DefaultIfEmpty(0).Max());
42
+
43
+ private static string GetFillClass(ReportChartItem item)
44
+ {
45
+ return item.Value > 0
46
+ ? "flex h-full items-center justify-end rounded-md text-[11px] font-semibold text-white transition-all"
47
+ : "h-full rounded-md";
48
+ }
49
+
50
+ private static string GetStageHelper(ReportChartItem item)
51
+ {
52
+ if (item.Value <= 0)
53
+ {
54
+ return $"No {item.Label.ToLowerInvariant()}";
55
+ }
56
+
57
+ return item.Detail ?? string.Empty;
58
+ }
59
+
60
+ private static string BuildTitle(ReportChartItem item, double percent)
61
+ {
62
+ return $"{item.Label}: {item.Value:0} ({percent:0}% of baseline)";
63
+ }
64
+ }
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/MetricComparisonCard.razor ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <ReportChartCard Title="@Title" Subtitle="@Subtitle" Value="@PrimaryValue" ValueLabel="@PrimaryLabel" Insight="@Insight">
2
+ <div class="space-y-4">
3
+ <div class="grid grid-cols-2 gap-3">
4
+ <div class="rounded-xl border border-slate-100 bg-slate-50 p-3">
5
+ <div class="text-[11px] font-semibold uppercase tracking-wider text-slate-400">@PrimaryLabel</div>
6
+ <div class="mt-1 text-2xl font-bold tabular-nums text-slate-950">@PrimaryValue</div>
7
+ </div>
8
+ <div class="rounded-xl border border-slate-100 bg-slate-50 p-3">
9
+ <div class="text-[11px] font-semibold uppercase tracking-wider text-slate-400">@SecondaryLabel</div>
10
+ <div class="mt-1 text-2xl font-bold tabular-nums text-slate-950">@SecondaryValue</div>
11
+ </div>
12
+ </div>
13
+ <StackedProgressBar Segments="Segments" EmptyText="No comparison data available." />
14
+ </div>
15
+ </ReportChartCard>
16
+
17
+ @code {
18
+ [Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
19
+ [Parameter] public string? Subtitle { get; set; }
20
+ [Parameter] public string PrimaryLabel { get; set; } = "Actual";
21
+ [Parameter] public string PrimaryValue { get; set; } = "0";
22
+ [Parameter] public string SecondaryLabel { get; set; } = "Target";
23
+ [Parameter] public string SecondaryValue { get; set; } = "0";
24
+ [Parameter] public string? Insight { get; set; }
25
+ [Parameter] public IReadOnlyList<StackedSegment> Segments { get; set; } = Array.Empty<StackedSegment>();
26
+ }
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/RadialMetricCard.razor ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <ReportChartCard Title="@Title" Subtitle="@Subtitle" Value="@Value" ValueLabel="@ValueLabel" Insight="@Insight">
2
+ <div class="flex items-center justify-center">
3
+ <div class="relative h-36 w-36">
4
+ <svg viewBox="0 0 160 160" class="h-36 w-36 -rotate-90" role="img" aria-label="@($"{Title}: {Percent:0}%")">
5
+ <circle cx="80" cy="80" r="58" fill="none" stroke="#e2e8f0" stroke-width="16" />
6
+ <circle cx="80" cy="80" r="58" fill="none" stroke="@Color" stroke-width="16" stroke-dasharray="@DashArray" stroke-linecap="round" />
7
+ </svg>
8
+ <div class="absolute inset-0 flex flex-col items-center justify-center">
9
+ <span class="text-3xl font-bold tabular-nums text-slate-950">@ClampedPercent.ToString("0")%</span>
10
+ <span class="text-[11px] font-semibold uppercase tracking-wider text-slate-400">@CenterLabel</span>
11
+ </div>
12
+ </div>
13
+ </div>
14
+ </ReportChartCard>
15
+
16
+ @code {
17
+ [Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
18
+ [Parameter] public string? Subtitle { get; set; }
19
+ [Parameter] public string? Value { get; set; }
20
+ [Parameter] public string? ValueLabel { get; set; }
21
+ [Parameter] public string? Insight { get; set; }
22
+ [Parameter] public string CenterLabel { get; set; } = "score";
23
+ [Parameter] public double Percent { get; set; }
24
+ [Parameter] public string Color { get; set; } = "#7c3aed";
25
+
26
+ private double ClampedPercent => Math.Clamp(Percent, 0, 100);
27
+ private string DashArray
28
+ {
29
+ get
30
+ {
31
+ const double radius = 58;
32
+ var circumference = 2 * Math.PI * radius;
33
+ var arc = circumference * ClampedPercent / 100;
34
+ return $"{arc:0.##} {(circumference - arc):0.##}";
35
+ }
36
+ }
37
+ }
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportChartCard.razor ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <section class="@CardClass">
2
+ <div class="flex items-start justify-between gap-4">
3
+ <div class="min-w-0">
4
+ <h3 class="truncate text-sm font-semibold text-slate-950">@Title</h3>
5
+ @if (!string.IsNullOrWhiteSpace(Subtitle))
6
+ {
7
+ <p class="mt-1 text-xs leading-5 text-slate-500">@Subtitle</p>
8
+ }
9
+ </div>
10
+ @if (!string.IsNullOrWhiteSpace(Value))
11
+ {
12
+ <div class="shrink-0 text-right">
13
+ <div class="text-2xl font-bold tabular-nums text-slate-950">@Value</div>
14
+ @if (!string.IsNullOrWhiteSpace(ValueLabel))
15
+ {
16
+ <div class="text-[11px] font-semibold uppercase tracking-wider text-slate-400">@ValueLabel</div>
17
+ }
18
+ </div>
19
+ }
20
+ </div>
21
+
22
+ <div class="@BodyClass">
23
+ @ChildContent
24
+ </div>
25
+
26
+ @if (!string.IsNullOrWhiteSpace(Insight))
27
+ {
28
+ <div class="mt-4 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-600">
29
+ @Insight
30
+ </div>
31
+ }
32
+ </section>
33
+
34
+ @code {
35
+ [Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
36
+ [Parameter] public string? Subtitle { get; set; }
37
+ [Parameter] public string? Value { get; set; }
38
+ [Parameter] public string? ValueLabel { get; set; }
39
+ [Parameter] public string? Insight { get; set; }
40
+ [Parameter] public string CardClass { get; set; } = "rounded-xl border border-slate-200 bg-white p-5 shadow-sm";
41
+ [Parameter] public string BodyClass { get; set; } = "mt-5";
42
+ [Parameter] public RenderFragment? ChildContent { get; set; }
43
+ }
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportChartModels.cs ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ namespace TaskTrackingSystem.WebApp.Components.Pages.Features.Reports.Components;
2
+
3
+ public sealed record ReportChartItem(string Label, double Value, string Color = "#7c3aed", string? Detail = null);
4
+
5
+ public sealed record StackedSegment(string Label, double Value, string Color, string? Detail = null);
6
+
7
+ public sealed record HeatmapCell(string Row, string Column, double Value, string? Detail = null);
8
+
9
+ public sealed record RiskMatrixPoint(string Label, int Severity, int Urgency, double Value, string Color = "#7c3aed", string? Detail = null);
10
+
11
+ public sealed record TopRiskItem(string Title, string Meta, double Score, string Badge, string Tone = "slate", string? Detail = null);
12
+
13
+ public sealed record SparklinePoint(string Label, double Value);
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/RiskMatrixCard.razor ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <ReportChartCard Title="@Title" Subtitle="@Subtitle" Insight="@Insight">
2
+ @if (!Points.Any())
3
+ {
4
+ <div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@EmptyText</div>
5
+ }
6
+ else
7
+ {
8
+ <div class="space-y-3">
9
+ <div class="grid grid-cols-[72px_repeat(3,minmax(0,1fr))] items-center gap-2">
10
+ <div></div>
11
+ @foreach (var label in UrgencyLabels)
12
+ {
13
+ <div class="rounded-lg bg-slate-50 px-2 py-1.5 text-center text-[11px] font-semibold uppercase tracking-wider text-slate-500">@label</div>
14
+ }
15
+ </div>
16
+
17
+ <div class="grid grid-cols-[72px_minmax(0,1fr)] gap-2">
18
+ <div class="flex items-center justify-center rounded-lg bg-slate-50 px-2 text-center text-[11px] font-semibold uppercase tracking-wider text-slate-500 [writing-mode:vertical-rl] rotate-180">
19
+ @SeverityAxisLabel
20
+ </div>
21
+ <div class="grid min-h-[260px] grid-cols-3 grid-rows-3 gap-2 rounded-xl border border-slate-100 bg-slate-50 p-2" role="img" aria-label="@Title">
22
+ @for (var y = 3; y >= 1; y--)
23
+ {
24
+ @for (var x = 1; x <= 3; x++)
25
+ {
26
+ var points = Points.Where(point => point.Severity == x && point.Urgency == y).ToList();
27
+ var total = points.Sum(point => point.Value);
28
+ var title = BuildCellTitle(points, x, y, total);
29
+ <div class="@GetCellClass(x, y, total)" title="@title">
30
+ <span class="@GetCountClass(total)">@total.ToString("0")</span>
31
+ <span class="@GetLabelClass(total)">@GetCellLabel(x, y, total)</span>
32
+ @if (total > 0)
33
+ {
34
+ <span class="mt-1 truncate text-[11px] text-slate-500">@BuildCellSummary(points)</span>
35
+ }
36
+ </div>
37
+ }
38
+ }
39
+ </div>
40
+ </div>
41
+
42
+ <div class="grid grid-cols-[72px_repeat(3,minmax(0,1fr))] items-center gap-2">
43
+ <div></div>
44
+ @foreach (var label in SeverityLabels)
45
+ {
46
+ <div class="text-center text-[11px] font-medium text-slate-400">@label</div>
47
+ }
48
+ </div>
49
+
50
+ <div class="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2 text-[11px] text-slate-500">
51
+ <span class="font-semibold uppercase tracking-wider text-slate-400">Legend</span>
52
+ <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-emerald-400"></span>Healthy</span>
53
+ <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-blue-400"></span>Watch</span>
54
+ <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-amber-400"></span>At risk</span>
55
+ <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-rose-400"></span>Critical</span>
56
+ </div>
57
+ </div>
58
+ }
59
+ </ReportChartCard>
60
+
61
+ @code {
62
+ [Parameter] public string Title { get; set; } = "Risk matrix";
63
+ [Parameter] public string? Subtitle { get; set; }
64
+ [Parameter] public string? Insight { get; set; }
65
+ [Parameter] public IReadOnlyList<RiskMatrixPoint> Points { get; set; } = Array.Empty<RiskMatrixPoint>();
66
+ [Parameter] public string EmptyText { get; set; } = "No matrix data available.";
67
+ [Parameter] public string SeverityAxisLabel { get; set; } = "Risk severity";
68
+ [Parameter] public IReadOnlyList<string> UrgencyLabels { get; set; } = new[] { "Low urgency", "Medium", "High urgency" };
69
+ [Parameter] public IReadOnlyList<string> SeverityLabels { get; set; } = new[] { "Low risk", "Medium", "High risk" };
70
+
71
+ private static string GetCellLabel(int severity, int urgency, double total)
72
+ {
73
+ if (total <= 0)
74
+ {
75
+ return "No projects";
76
+ }
77
+
78
+ return (severity, urgency) switch
79
+ {
80
+ (3, 3) => "Critical",
81
+ (3, _) or (_, 3) => "At risk",
82
+ (1, 1) => "Healthy",
83
+ _ => "Watch"
84
+ };
85
+ }
86
+
87
+ private static string GetCellClass(int severity, int urgency, double total)
88
+ {
89
+ if (total <= 0)
90
+ {
91
+ return "flex min-h-[72px] flex-col justify-center rounded-lg border border-slate-100 bg-white p-3 text-center transition-colors hover:border-slate-200";
92
+ }
93
+
94
+ var score = severity * urgency;
95
+ var tone = score >= 9
96
+ ? "border-rose-200 bg-rose-50"
97
+ : score >= 6
98
+ ? "border-amber-200 bg-amber-50"
99
+ : score >= 3
100
+ ? "border-blue-100 bg-blue-50"
101
+ : "border-emerald-100 bg-emerald-50";
102
+
103
+ return $"flex min-h-[72px] flex-col justify-center rounded-lg border p-3 text-center transition-colors hover:border-slate-300 hover:bg-white {tone}";
104
+ }
105
+
106
+ private static string GetCountClass(double total) => total > 0
107
+ ? "text-2xl font-bold tabular-nums text-slate-950"
108
+ : "text-xl font-bold tabular-nums text-slate-300";
109
+
110
+ private static string GetLabelClass(double total) => total > 0
111
+ ? "mt-1 text-[11px] font-semibold uppercase tracking-wider text-slate-600"
112
+ : "mt-1 text-[11px] font-medium text-slate-400";
113
+
114
+ private static string BuildCellSummary(IReadOnlyList<RiskMatrixPoint> points)
115
+ {
116
+ return string.Join(", ", points.OrderByDescending(point => point.Value).Take(2).Select(point => point.Label));
117
+ }
118
+
119
+ private static string BuildCellTitle(IReadOnlyList<RiskMatrixPoint> points, int severity, int urgency, double total)
120
+ {
121
+ if (total <= 0)
122
+ {
123
+ return $"No projects. Severity {severity}, urgency {urgency}.";
124
+ }
125
+
126
+ var names = string.Join(Environment.NewLine, points.OrderByDescending(point => point.Value).Select(point => $"{point.Label}: {point.Detail ?? $"{point.Value:0} item(s)"}"));
127
+ return $"{total:0} project(s){Environment.NewLine}{names}";
128
+ }
129
+ }
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/StackedProgressBar.razor ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="space-y-3">
2
+ @if (!Segments.Any() || Total <= 0)
3
+ {
4
+ <div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 py-8 text-center text-sm text-slate-400">@EmptyText</div>
5
+ }
6
+ else
7
+ {
8
+ <div class="flex h-4 overflow-hidden rounded-full bg-slate-100" role="img" aria-label="@AriaLabel">
9
+ @foreach (var segment in Segments)
10
+ {
11
+ var width = segment.Value / Total * 100;
12
+ <div title="@($"{segment.Label}: {segment.Value:0}")" class="h-full" style="width: @width%; background-color: @segment.Color;"></div>
13
+ }
14
+ </div>
15
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
16
+ @foreach (var segment in Segments)
17
+ {
18
+ var pct = segment.Value / Total * 100;
19
+ <div class="flex items-center justify-between rounded-lg border border-slate-100 bg-slate-50 px-3 py-2">
20
+ <span class="flex min-w-0 items-center gap-2 text-xs font-medium text-slate-700">
21
+ <span class="h-2.5 w-2.5 shrink-0 rounded-full" style="background-color: @segment.Color"></span>
22
+ <span class="truncate">@segment.Label</span>
23
+ </span>
24
+ <span class="text-xs font-semibold tabular-nums text-slate-900">@segment.Value.ToString("0.#") · @pct.ToString("0")%</span>
25
+ </div>
26
+ }
27
+ </div>
28
+ }
29
+ </div>
30
+
31
+ @code {
32
+ [Parameter] public IReadOnlyList<StackedSegment> Segments { get; set; } = Array.Empty<StackedSegment>();
33
+ [Parameter] public string EmptyText { get; set; } = "No data available.";
34
+ [Parameter] public string AriaLabel { get; set; } = "Stacked progress";
35
+ private double Total => Segments.Sum(segment => Math.Max(0, segment.Value));
36
+ }
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/TopRiskList.razor ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="space-y-3">
2
+ @if (!Items.Any())
3
+ {
4
+ <div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@EmptyText</div>
5
+ }
6
+ else
7
+ {
8
+ @foreach (var item in Items.Take(Limit))
9
+ {
10
+ <div class="rounded-xl border border-slate-100 bg-slate-50 px-4 py-3">
11
+ <div class="flex items-start justify-between gap-3">
12
+ <div class="min-w-0">
13
+ <div class="truncate text-sm font-semibold text-slate-950">@item.Title</div>
14
+ <div class="mt-1 text-xs text-slate-500">@item.Meta</div>
15
+ </div>
16
+ <div class="shrink-0 rounded-full px-2.5 py-1 text-[11px] font-bold @GetBadgeClass(item.Tone)">@item.Badge</div>
17
+ </div>
18
+ <div class="mt-3 h-2 overflow-hidden rounded-full bg-white">
19
+ <div class="h-full rounded-full @GetBarClass(item.Tone)" style="width: @Math.Clamp(item.Score, 0, 100)%"></div>
20
+ </div>
21
+ @if (!string.IsNullOrWhiteSpace(item.Detail))
22
+ {
23
+ <p class="mt-2 text-xs text-slate-500">@item.Detail</p>
24
+ }
25
+ </div>
26
+ }
27
+ }
28
+ </div>
29
+
30
+ @code {
31
+ [Parameter] public IReadOnlyList<TopRiskItem> Items { get; set; } = Array.Empty<TopRiskItem>();
32
+ [Parameter] public int Limit { get; set; } = 5;
33
+ [Parameter] public string EmptyText { get; set; } = "No risk items to show.";
34
+
35
+ private static string GetBadgeClass(string tone) => tone switch
36
+ {
37
+ "rose" => "bg-rose-50 text-rose-700",
38
+ "amber" => "bg-amber-50 text-amber-700",
39
+ "emerald" => "bg-emerald-50 text-emerald-700",
40
+ "blue" => "bg-blue-50 text-blue-700",
41
+ _ => "bg-slate-100 text-slate-700"
42
+ };
43
+
44
+ private static string GetBarClass(string tone) => tone switch
45
+ {
46
+ "rose" => "bg-rose-500",
47
+ "amber" => "bg-amber-500",
48
+ "emerald" => "bg-emerald-500",
49
+ "blue" => "bg-blue-500",
50
+ _ => "bg-violet-600"
51
+ };
52
+ }
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/TrendSparklineCard.razor ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <ReportChartCard Title="@Title" Subtitle="@Subtitle" Value="@Value" ValueLabel="@ValueLabel" Insight="@Insight">
2
+ @if (Points.Count < 2)
3
+ {
4
+ <div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@EmptyText</div>
5
+ }
6
+ else
7
+ {
8
+ <svg viewBox="0 0 320 120" class="h-28 w-full" role="img" aria-label="@Title">
9
+ <path d="@AreaPath" fill="@Color" opacity="0.12" />
10
+ <path d="@LinePath" fill="none" stroke="@Color" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
11
+ @foreach (var point in SvgPoints)
12
+ {
13
+ <circle cx="@point.X" cy="@point.Y" r="4" fill="white" stroke="@Color" stroke-width="3" />
14
+ }
15
+ </svg>
16
+ <div class="mt-2 flex justify-between text-[11px] font-medium text-slate-400">
17
+ <span>@Points.First().Label</span>
18
+ <span>@Points.Last().Label</span>
19
+ </div>
20
+ }
21
+ </ReportChartCard>
22
+
23
+ @code {
24
+ [Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
25
+ [Parameter] public string? Subtitle { get; set; }
26
+ [Parameter] public string? Value { get; set; }
27
+ [Parameter] public string? ValueLabel { get; set; }
28
+ [Parameter] public string? Insight { get; set; }
29
+ [Parameter] public IReadOnlyList<SparklinePoint> Points { get; set; } = Array.Empty<SparklinePoint>();
30
+ [Parameter] public string Color { get; set; } = "#7c3aed";
31
+ [Parameter] public string EmptyText { get; set; } = "Not enough data for a trend yet.";
32
+
33
+ private IReadOnlyList<(double X, double Y)> SvgPoints => BuildSvgPoints();
34
+ private string LinePath => SvgPoints.Any() ? $"M {string.Join(" L ", SvgPoints.Select(p => $"{p.X:0},{p.Y:0}"))}" : string.Empty;
35
+ private string AreaPath => SvgPoints.Any() ? $"{LinePath} L {SvgPoints.Last().X:0},112 L {SvgPoints.First().X:0},112 Z" : string.Empty;
36
+
37
+ private IReadOnlyList<(double X, double Y)> BuildSvgPoints()
38
+ {
39
+ var max = Math.Max(1, Points.Max(point => point.Value));
40
+ var min = Math.Min(0, Points.Min(point => point.Value));
41
+ var range = Math.Max(1, max - min);
42
+
43
+ return Points.Select((point, index) =>
44
+ {
45
+ var x = 16 + index * (288d / Math.Max(1, Points.Count - 1));
46
+ var y = 104 - ((point.Value - min) / range * 88);
47
+ return (x, y);
48
+ }).ToList();
49
+ }
50
+ }
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/WorkloadHeatmap.razor ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <ReportChartCard Title="@Title" Subtitle="@Subtitle" Insight="@Insight">
2
+ @if (!Rows.Any() || !Columns.Any())
3
+ {
4
+ <div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@EmptyText</div>
5
+ }
6
+ else
7
+ {
8
+ <div class="overflow-x-auto">
9
+ <div class="min-w-[520px]">
10
+ <div class="grid gap-2" style="grid-template-columns: 150px repeat(@Columns.Count, minmax(72px, 1fr));">
11
+ <div></div>
12
+ @foreach (var column in Columns)
13
+ {
14
+ <div class="text-center text-[11px] font-semibold uppercase tracking-wider text-slate-400">@column</div>
15
+ }
16
+ @foreach (var row in Rows)
17
+ {
18
+ <div class="truncate py-2 text-xs font-semibold text-slate-700">@row</div>
19
+ @foreach (var column in Columns)
20
+ {
21
+ var cell = Cells.FirstOrDefault(item => item.Row == row && item.Column == column);
22
+ var value = cell?.Value ?? 0;
23
+ <div class="@GetCellClass(value)" title="@cell?.Detail">
24
+ <span class="text-sm font-bold tabular-nums">@value.ToString("0.#")</span>
25
+ </div>
26
+ }
27
+ }
28
+ </div>
29
+ </div>
30
+ </div>
31
+ }
32
+ </ReportChartCard>
33
+
34
+ @code {
35
+ [Parameter] public string Title { get; set; } = "Workload heatmap";
36
+ [Parameter] public string? Subtitle { get; set; }
37
+ [Parameter] public string? Insight { get; set; }
38
+ [Parameter] public IReadOnlyList<string> Rows { get; set; } = Array.Empty<string>();
39
+ [Parameter] public IReadOnlyList<string> Columns { get; set; } = Array.Empty<string>();
40
+ [Parameter] public IReadOnlyList<HeatmapCell> Cells { get; set; } = Array.Empty<HeatmapCell>();
41
+ [Parameter] public string EmptyText { get; set; } = "No workload data available.";
42
+
43
+ private static string GetCellClass(double value)
44
+ {
45
+ var tone = value >= 75
46
+ ? "border-rose-200 bg-rose-50 text-rose-700"
47
+ : value >= 45
48
+ ? "border-amber-200 bg-amber-50 text-amber-700"
49
+ : value > 0
50
+ ? "border-blue-100 bg-blue-50 text-blue-700"
51
+ : "border-slate-100 bg-white text-slate-300";
52
+
53
+ return $"flex h-12 items-center justify-center rounded-lg border text-center {tone}";
54
+ }
55
+ }
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/EmployeeReport.razor CHANGED
@@ -111,14 +111,29 @@
111
  {
112
  @if (isChartView)
113
  {
114
- <!-- Chart View: SVG Horizontal Bar Chart showing Efficiency -->
115
- <ChartCard Title="Workload by Employee" TitleClass="font-semibold text-slate-900 mb-4 font-sans text-md" BodyClass="mt-0">
116
- <ChildContent>
117
- <div class="max-w-2xl">
118
- <ProgressListChart Items="EmployeeWorkloadItems" MaxValue="EmployeeWorkloadMax" EmptyText="No data available." />
119
- </div>
120
- </ChildContent>
121
- </ChartCard>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  }
123
  else
124
  {
@@ -201,15 +216,52 @@
201
  private bool filterAssignToMe = false;
202
  private bool filterAssignToMyTeam = false;
203
 
204
- private List<ProgressListChartItem> EmployeeWorkloadItems => allEmployees
205
- .Select(emp => new ProgressListChartItem(
206
- $"{emp.FullName} ({emp.Username})",
207
- Math.Min(emp.OpenIssues * 10, 100),
208
- $"{emp.OpenIssues} open, {emp.OverdueIssues} overdue, {emp.ActualHours:N1} hrs",
209
- "#7c3aed"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  .ToList();
211
 
212
- private double EmployeeWorkloadMax => Math.Max(100, EmployeeWorkloadItems.Select(item => item.Value).DefaultIfEmpty(0).Max());
 
 
 
 
 
213
 
214
  // Search and Pagination
215
  private string searchInput = "";
@@ -419,4 +471,10 @@
419
  "Declining" => "bg-rose-50 text-rose-700",
420
  _ => "bg-blue-50 text-blue-700"
421
  };
 
 
 
 
 
 
422
  }
 
111
  {
112
  @if (isChartView)
113
  {
114
+ <div class="grid grid-cols-1 gap-5 xl:grid-cols-12">
115
+ <div class="xl:col-span-7">
116
+ <WorkloadHeatmap Title="Workload heatmap" Subtitle="Capacity signals across assigned work, open issues, overdue issues, and hours." Rows="EmployeeHeatmapRows" Columns="EmployeeHeatmapColumns" Cells="EmployeeHeatmapCells" Insight="Darker cells mean someone may need support or scope adjustment." />
117
+ </div>
118
+ <div class="xl:col-span-5">
119
+ <ReportChartCard Title="Capacity support list" Subtitle="Highest support signals, phrased as capacity risk." Insight="Use this to route help and remove blockers, not to rank people negatively.">
120
+ <TopRiskList Items="EmployeeCapacityRiskItems" EmptyText="No capacity risks in the current filter." />
121
+ </ReportChartCard>
122
+ </div>
123
+ <div class="xl:col-span-5">
124
+ <ReportChartCard Title="Completed vs open issues" Subtitle="Team execution balance by issue state." Value="@CompletedIssueTotal.ToString()" ValueLabel="completed tasks">
125
+ <StackedProgressBar Segments="EmployeeCompletionSegments" AriaLabel="Completed versus open employee workload" />
126
+ </ReportChartCard>
127
+ </div>
128
+ <div class="xl:col-span-4">
129
+ <ReportChartCard Title="Overdue support" Subtitle="Employees with overdue issue load." Insight="Treat overdue counts as support requests.">
130
+ <TopRiskList Items="EmployeeOverdueItems" EmptyText="No overdue support needs found." />
131
+ </ReportChartCard>
132
+ </div>
133
+ <div class="xl:col-span-3">
134
+ <TrendSparklineCard Title="Completion pattern" Subtitle="Completion rate across visible employees." Value="@($"{AverageCompletionRate:0}%")" ValueLabel="avg rate" Points="EmployeeCompletionSparkline" Color="#3b82f6" Insight="A quick view of whether completion is clustered or balanced." />
135
+ </div>
136
+ </div>
137
  }
138
  else
139
  {
 
216
  private bool filterAssignToMe = false;
217
  private bool filterAssignToMyTeam = false;
218
 
219
+ private List<string> EmployeeHeatmapRows => allEmployees.Take(6).Select(emp => emp.FullName).ToList();
220
+ private List<string> EmployeeHeatmapColumns => new() { "Assigned", "Open", "Overdue", "Hours" };
221
+ private int CompletedIssueTotal => allEmployees.Sum(emp => emp.CompletedCount);
222
+ private double AverageCompletionRate => allEmployees.Any() ? allEmployees.Average(emp => emp.CompletionRate) : 0;
223
+
224
+ private List<HeatmapCell> EmployeeHeatmapCells => allEmployees.Take(6)
225
+ .SelectMany(emp => new[]
226
+ {
227
+ new HeatmapCell(emp.FullName, "Assigned", Math.Min(100, emp.AssignedCount * 8), $"{emp.AssignedCount} assigned tasks"),
228
+ new HeatmapCell(emp.FullName, "Open", Math.Min(100, emp.OpenIssues * 12), $"{emp.OpenIssues} open issues"),
229
+ new HeatmapCell(emp.FullName, "Overdue", Math.Min(100, emp.OverdueIssues * 20), $"{emp.OverdueIssues} overdue issues"),
230
+ new HeatmapCell(emp.FullName, "Hours", Math.Min(100, (double)emp.ActualHours * 5), $"{emp.ActualHours:N1} actual hours")
231
+ })
232
+ .ToList();
233
+
234
+ private List<TopRiskItem> EmployeeCapacityRiskItems => allEmployees
235
+ .Select(emp =>
236
+ {
237
+ var score = Math.Min(100, (emp.OpenIssues * 14) + (emp.OverdueIssues * 24) + (emp.AssignedCount * 4) + (double)emp.ActualHours);
238
+ var tone = score >= 70 ? "rose" : score >= 45 ? "amber" : "blue";
239
+ return new TopRiskItem(emp.FullName, $"{emp.WorkloadLevel} · {emp.ActualHours:N1}h tracked", score, "needs support", tone, $"{emp.OpenIssues} open · {emp.OverdueIssues} overdue");
240
+ })
241
+ .OrderByDescending(item => item.Score)
242
+ .Take(5)
243
+ .ToList();
244
+
245
+ private List<StackedSegment> EmployeeCompletionSegments => new()
246
+ {
247
+ new("Completed tasks", allEmployees.Sum(emp => emp.CompletedCount), "#10b981"),
248
+ new("Assigned tasks", allEmployees.Sum(emp => Math.Max(0, emp.AssignedCount - emp.CompletedCount)), "#3b82f6"),
249
+ new("Open issues", allEmployees.Sum(emp => emp.OpenIssues), "#f59e0b")
250
+ };
251
+
252
+ private List<TopRiskItem> EmployeeOverdueItems => allEmployees
253
+ .Where(emp => emp.OverdueIssues > 0)
254
+ .OrderByDescending(emp => emp.OverdueIssues)
255
+ .Take(5)
256
+ .Select(emp => new TopRiskItem(emp.FullName, $"{emp.OpenIssues} open issues", Math.Min(100, emp.OverdueIssues * 25), $"{emp.OverdueIssues} overdue", "rose", "Capacity risk"))
257
  .ToList();
258
 
259
+ private List<SparklinePoint> EmployeeCompletionSparkline => allEmployees
260
+ .OrderByDescending(emp => emp.AssignedCount)
261
+ .Take(8)
262
+ .Select(emp => new SparklinePoint(BuildShortName(emp.FullName), emp.CompletionRate))
263
+ .DefaultIfEmpty(new SparklinePoint("No data", 0))
264
+ .ToList();
265
 
266
  // Search and Pagination
267
  private string searchInput = "";
 
471
  "Declining" => "bg-rose-50 text-rose-700",
472
  _ => "bg-blue-50 text-blue-700"
473
  };
474
+
475
+ private static string BuildShortName(string fullName)
476
+ {
477
+ var parts = fullName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
478
+ return parts.Length == 0 ? "User" : parts.Length == 1 ? parts[0] : $"{parts[0]} {parts[^1][0]}.";
479
+ }
480
  }
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/IssueReport.razor CHANGED
@@ -98,11 +98,35 @@
98
  <div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
99
  @if (isChartView)
100
  {
101
- <div class="grid grid-cols-1 gap-5 md:grid-cols-2">
102
- @BarChart("Issues by Status", StatusChart)
103
- @BarChart("Issues by Priority", PriorityChart)
104
- @BarChart("Issues by Employee", EmployeeChart)
105
- @BarChart("Issues by Project", ProjectChart)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  </div>
107
  }
108
  else
@@ -283,27 +307,67 @@
283
  private IReadOnlyList<(string Label, int Value)> EmployeeChart => FilteredIssues.GroupBy(i => i.AssignedToName ?? "Unassigned").OrderByDescending(g => g.Count()).Take(5).Select(g => (g.Key, g.Count())).ToList();
284
  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();
285
  private IEnumerable<string> Insights => new[] { $"{OverdueIssues} overdue issues need attention.", $"{BlockedIssues} issues are blocked.", $"Actual hours are {FilteredIssues.Sum(i => i.ActualHours ?? 0):N1} against {FilteredIssues.Sum(i => i.EstimatedHours ?? 0):N1} estimated." };
 
 
 
 
286
 
287
- private RenderFragment MetricCard(string label, string value, string icon, string color) => builder =>
288
  {
289
- builder.OpenElement(0, "div");
290
- builder.AddAttribute(1, "class", $"rounded-xl border border-{color}-100 bg-{color}-50 p-5 shadow-sm");
291
- 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>");
292
- builder.CloseElement();
 
293
  };
294
 
295
- private RenderFragment BarChart(string title, IReadOnlyList<(string Label, int Value)> rows) => builder =>
296
  {
297
- var max = Math.Max(1, rows.Select(r => r.Value).DefaultIfEmpty(0).Max());
298
- builder.OpenElement(0, "div");
299
- builder.AddAttribute(1, "class", "rounded-xl border border-slate-100 bg-slate-50 p-4");
300
- builder.AddMarkupContent(2, $"<h3 class=\"mb-4 text-sm font-semibold text-slate-900\">{title}</h3>");
301
- var seq = 3;
302
- foreach (var row in rows)
 
 
 
303
  {
304
- var width = row.Value * 100 / max;
305
- builder.AddMarkupContent(seq++, $"<div class=\"mb-3\"><div class=\"mb-1 flex justify-between text-xs font-semibold text-slate-600\"><span>{row.Label}</span><span>{row.Value}</span></div><div class=\"h-2.5 rounded-full bg-white\"><div class=\"h-full rounded-full bg-violet-600\" style=\"width:{width}%\"></div></div></div>");
306
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  builder.CloseElement();
308
  };
309
 
 
98
  <div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
99
  @if (isChartView)
100
  {
101
+ <div class="grid grid-cols-1 gap-5 xl:grid-cols-12">
102
+ <div class="xl:col-span-5">
103
+ <ReportChartCard Title="Issue lifecycle funnel" Subtitle="How issues move from backlog to resolution." Insight="A large in-progress or blocked band is a daily execution signal.">
104
+ <FunnelChart Items="IssueFunnelItems" />
105
+ </ReportChartCard>
106
+ </div>
107
+ <div class="xl:col-span-4">
108
+ <ReportChartCard Title="Issue status donut" Subtitle="Current status distribution, including overdue pressure." Insight="@IssueStatusInsight">
109
+ <DonutStatusChart Items="IssueStatusItems" CenterLabel="issues" />
110
+ </ReportChartCard>
111
+ </div>
112
+ <div class="xl:col-span-3">
113
+ <RadialMetricCard Title="Blocked / overdue" Subtitle="Execution risk in the filtered issue set." Percent="@BlockedOverduePercent" Value="@((BlockedIssues + OverdueIssues).ToString())" ValueLabel="risk issues" CenterLabel="risk" Color="#ef4444" Insight="Focus standup discussion here first." />
114
+ </div>
115
+ <div class="xl:col-span-4">
116
+ <ReportChartCard Title="Issues by project" Subtitle="Where issue volume is concentrated." Insight="Useful for project-level triage and planning health.">
117
+ <TopRiskList Items="IssueProjectItems" EmptyText="No project issues found." />
118
+ </ReportChartCard>
119
+ </div>
120
+ <div class="xl:col-span-4">
121
+ <ReportChartCard Title="Issues by assignee" Subtitle="Support needs by assignee." Insight="Use as a support signal, especially when overdue or blocked items cluster.">
122
+ <TopRiskList Items="IssueAssigneeItems" EmptyText="No assignee issues found." />
123
+ </ReportChartCard>
124
+ </div>
125
+ <div class="xl:col-span-4">
126
+ <ReportChartCard Title="Resolution aging" Subtitle="How close issues are to or past due date." Insight="Older overdue buckets should be cleared before new scope expands.">
127
+ <AgingBucketChart Buckets="IssueAgingBuckets" />
128
+ </ReportChartCard>
129
+ </div>
130
  </div>
131
  }
132
  else
 
307
  private IReadOnlyList<(string Label, int Value)> EmployeeChart => FilteredIssues.GroupBy(i => i.AssignedToName ?? "Unassigned").OrderByDescending(g => g.Count()).Take(5).Select(g => (g.Key, g.Count())).ToList();
308
  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();
309
  private IEnumerable<string> Insights => new[] { $"{OverdueIssues} overdue issues need attention.", $"{BlockedIssues} issues are blocked.", $"Actual hours are {FilteredIssues.Sum(i => i.ActualHours ?? 0):N1} against {FilteredIssues.Sum(i => i.EstimatedHours ?? 0):N1} estimated." };
310
+ private double BlockedOverduePercent => FilteredIssues.Any() ? (BlockedIssues + OverdueIssues) / (double)FilteredIssues.Count() * 100 : 0;
311
+ private string IssueStatusInsight => OpenIssues > FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Done)
312
+ ? $"{OpenIssues} issues are still open across the selected scope."
313
+ : "Resolved issues are leading this filtered set.";
314
 
315
+ private List<ReportChartItem> IssueFunnelItems => new()
316
  {
317
+ new("Created", FilteredIssues.Count(), "#7c3aed", "All reported issues"),
318
+ new("Open", OpenIssues, "#3b82f6", "Unresolved issues"),
319
+ new("In Progress", FilteredIssues.Count(i => i.StatusId == AppTaskStatus.InProgress), "#06b6d4", "Currently being worked"),
320
+ new("Blocked", BlockedIssues, "#f59e0b", "Needs unblock review"),
321
+ new("Resolved", FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Done), "#10b981", "Completed issues")
322
  };
323
 
324
+ private List<ReportChartItem> IssueStatusItems => new()
325
  {
326
+ new("To Do", FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Todo), "#94a3b8"),
327
+ new("In Progress", FilteredIssues.Count(i => i.StatusId == AppTaskStatus.InProgress), "#3b82f6"),
328
+ new("Done", FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Done), "#10b981"),
329
+ new("Overdue", OverdueIssues, "#ef4444")
330
+ };
331
+
332
+ private List<TopRiskItem> IssueProjectItems => FilteredIssues
333
+ .GroupBy(i => i.ProjectName)
334
+ .Select(g =>
335
  {
336
+ var blocked = g.Count(i => i.IsBlocked);
337
+ var overdue = g.Count(IsOverdue);
338
+ var score = Math.Min(100, (g.Count() * 10) + (blocked * 25) + (overdue * 20));
339
+ return new TopRiskItem(g.Key, $"{g.Count()} issues · {blocked} blocked", score, overdue > 0 ? $"{overdue} overdue" : $"{g.Count()} issues", overdue > 0 || blocked > 0 ? "rose" : "blue");
340
+ })
341
+ .OrderByDescending(item => item.Score)
342
+ .Take(5)
343
+ .ToList();
344
+
345
+ private List<TopRiskItem> IssueAssigneeItems => FilteredIssues
346
+ .GroupBy(i => i.AssignedToName ?? "Unassigned")
347
+ .Select(g =>
348
+ {
349
+ var blocked = g.Count(i => i.IsBlocked);
350
+ var overdue = g.Count(IsOverdue);
351
+ var score = Math.Min(100, (g.Count() * 12) + (blocked * 22) + (overdue * 18));
352
+ return new TopRiskItem(g.Key, $"{g.Count()} assigned issues", score, blocked > 0 ? $"{blocked} blocked" : $"{overdue} overdue", blocked > 0 || overdue > 0 ? "amber" : "blue", "Support signal");
353
+ })
354
+ .OrderByDescending(item => item.Score)
355
+ .Take(5)
356
+ .ToList();
357
+
358
+ private List<ReportChartItem> IssueAgingBuckets => new()
359
+ {
360
+ new("Due later", FilteredIssues.Count(i => !IsOverdue(i) && i.DueDate.Date > DateTime.Today.AddDays(3)), "#3b82f6"),
361
+ new("Due soon", FilteredIssues.Count(i => !IsOverdue(i) && i.DueDate.Date <= DateTime.Today.AddDays(3)), "#f59e0b"),
362
+ new("1-7d late", FilteredIssues.Count(i => IsOverdue(i) && (DateTime.Today - i.DueDate.Date).Days <= 7), "#fb7185"),
363
+ new("8d+ late", FilteredIssues.Count(i => IsOverdue(i) && (DateTime.Today - i.DueDate.Date).Days > 7), "#ef4444")
364
+ };
365
+
366
+ private RenderFragment MetricCard(string label, string value, string icon, string color) => builder =>
367
+ {
368
+ builder.OpenElement(0, "div");
369
+ builder.AddAttribute(1, "class", $"rounded-xl border border-{color}-100 bg-{color}-50 p-5 shadow-sm");
370
+ 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>");
371
  builder.CloseElement();
372
  };
373
 
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/OverdueTasksReport.razor CHANGED
@@ -23,7 +23,15 @@
23
  <h2 class="text-3xl font-bold tracking-tight text-slate-900">Overdue &amp; Delayed Tasks</h2>
24
  <p class="text-slate-500 mt-1">Track tasks that have passed their due date or are at risk of delay.</p>
25
  </div>
26
- <div class="flex items-center space-x-3">
 
 
 
 
 
 
 
 
27
  <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">
28
  <i data-lucide="download" class="h-4 w-4 mr-2"></i> Export Excel
29
  </button>
@@ -135,7 +143,34 @@
135
  }
136
  else
137
  {
138
- <!-- Results Table -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
140
  <div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
141
  <h3 class="text-sm font-semibold text-slate-700">
@@ -240,11 +275,13 @@
240
  OnPageChanged="HandlePageChanged"
241
  OnPageSizeChanged="HandlePageSizeChanged" />
242
  </div>
 
243
  }
244
  </div>
245
 
246
  @code {
247
  private bool isLoading = true;
 
248
  private List<OverdueCriticalTaskDto> allOverdue = new();
249
  private List<OverdueCriticalTaskDto> overdueReportTasks = new();
250
  private PagedResult<OverdueCriticalTaskDto>? overdueReportPage;
@@ -271,6 +308,48 @@
271
  private bool canUpdateTask = false;
272
 
273
  private int TotalPages => overdueReportPage?.TotalPages ?? 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
 
275
  protected override async Task OnInitializedAsync()
276
  {
@@ -357,6 +436,11 @@
357
  await LoadOverdueReport();
358
  }
359
 
 
 
 
 
 
360
  private async Task HandlePageChanged(int page)
361
  {
362
  currentPage = page;
@@ -438,4 +522,13 @@
438
  3 => "Manager",
439
  _ => "None"
440
  };
 
 
 
 
 
 
 
 
 
441
  }
 
23
  <h2 class="text-3xl font-bold tracking-tight text-slate-900">Overdue &amp; Delayed Tasks</h2>
24
  <p class="text-slate-500 mt-1">Track tasks that have passed their due date or are at risk of delay.</p>
25
  </div>
26
+ <div class="flex flex-wrap items-center gap-3">
27
+ <div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
28
+ <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">
29
+ <i data-lucide="list" class="h-3.5 w-3.5 mr-1"></i> List View
30
+ </button>
31
+ <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">
32
+ <i data-lucide="bar-chart-3" class="h-3.5 w-3.5 mr-1"></i> Chart View
33
+ </button>
34
+ </div>
35
  <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">
36
  <i data-lucide="download" class="h-4 w-4 mr-2"></i> Export Excel
37
  </button>
 
143
  }
144
  else
145
  {
146
+ @if (isChartView)
147
+ {
148
+ <div class="grid grid-cols-1 gap-5 xl:grid-cols-12">
149
+ <div class="xl:col-span-7">
150
+ <ReportChartCard Title="Aging buckets" Subtitle="How long delayed work has been waiting." Insight="@AgingInsight">
151
+ <AgingBucketChart Buckets="AgingBuckets" />
152
+ </ReportChartCard>
153
+ </div>
154
+ <div class="xl:col-span-5">
155
+ <RadialMetricCard Title="High-priority overdue" Subtitle="Critical work that needs immediate support." Percent="@HighPriorityOverduePercent" Value="@criticalCount.ToString()" ValueLabel="high priority" CenterLabel="critical" Color="#ef4444" Insight="Phrase these as capacity risks so teams can unblock work, not assign blame." />
156
+ </div>
157
+ <div class="xl:col-span-6">
158
+ <ReportChartCard Title="Delays by project" Subtitle="Project areas where delay concentration is highest." Insight="Use this to rebalance planning pressure across projects.">
159
+ <TopRiskList Items="DelayProjectItems" EmptyText="No project delay concentration found." />
160
+ </ReportChartCard>
161
+ </div>
162
+ <div class="xl:col-span-6">
163
+ <ReportChartCard Title="Delays by assignee" Subtitle="People who may need support or scope adjustment." Insight="Support signal only: high counts should trigger help, not blame.">
164
+ <TopRiskList Items="DelayAssigneeItems" EmptyText="No assignee delay concentration found." />
165
+ </ReportChartCard>
166
+ </div>
167
+ <div class="xl:col-span-12">
168
+ <RiskMatrixCard Title="Severity vs days overdue" Subtitle="High priority and older delays sit in the upper-right risk zone." Points="OverdueRiskMatrix" Insight="The matrix combines priority severity with age of delay to show triage order." />
169
+ </div>
170
+ </div>
171
+ }
172
+ else
173
+ {
174
  <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
175
  <div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
176
  <h3 class="text-sm font-semibold text-slate-700">
 
275
  OnPageChanged="HandlePageChanged"
276
  OnPageSizeChanged="HandlePageSizeChanged" />
277
  </div>
278
+ }
279
  }
280
  </div>
281
 
282
  @code {
283
  private bool isLoading = true;
284
+ private bool isChartView = false;
285
  private List<OverdueCriticalTaskDto> allOverdue = new();
286
  private List<OverdueCriticalTaskDto> overdueReportTasks = new();
287
  private PagedResult<OverdueCriticalTaskDto>? overdueReportPage;
 
308
  private bool canUpdateTask = false;
309
 
310
  private int TotalPages => overdueReportPage?.TotalPages ?? 0;
311
+ private double HighPriorityOverduePercent => overdueCount > 0 ? criticalCount / (double)overdueCount * 100 : 0;
312
+ private string AgingInsight => avgDaysOverdue > 7
313
+ ? $"Average delay is {avgDaysOverdue} days; focus on older buckets first."
314
+ : "Most delayed work is still in a manageable window.";
315
+
316
+ private List<ReportChartItem> AgingBuckets => new()
317
+ {
318
+ new("Due soon", allOverdue.Count(t => t.DaysOverdue <= 0), "#f59e0b", "not overdue yet"),
319
+ new("1-3d", allOverdue.Count(t => t.DaysOverdue >= 1 && t.DaysOverdue <= 3), "#3b82f6", "early delay"),
320
+ new("4-7d", allOverdue.Count(t => t.DaysOverdue >= 4 && t.DaysOverdue <= 7), "#f59e0b", "needs action"),
321
+ new("8d+", allOverdue.Count(t => t.DaysOverdue >= 8), "#ef4444", "high risk")
322
+ };
323
+
324
+ private List<TopRiskItem> DelayProjectItems => allOverdue
325
+ .GroupBy(t => t.ProjectName)
326
+ .Select(g =>
327
+ {
328
+ var overdue = g.Count(t => t.DaysOverdue > 0);
329
+ var high = g.Count(t => t.PriorityName == "High");
330
+ var score = Math.Min(100, (overdue * 18) + (high * 15) + g.Sum(t => Math.Max(0, t.DaysOverdue)));
331
+ return new TopRiskItem(g.Key, $"{overdue} overdue · {high} high priority", score, $"{g.Count()} tasks", score >= 70 ? "rose" : "amber", $"{g.Sum(t => t.OverdueIssues)} overdue issues");
332
+ })
333
+ .OrderByDescending(item => item.Score)
334
+ .Take(5)
335
+ .ToList();
336
+
337
+ private List<TopRiskItem> DelayAssigneeItems => allOverdue
338
+ .GroupBy(t => string.IsNullOrWhiteSpace(t.AssignedTo) ? "Unassigned" : t.AssignedTo!)
339
+ .Select(g =>
340
+ {
341
+ var overdue = g.Count(t => t.DaysOverdue > 0);
342
+ var score = Math.Min(100, (overdue * 20) + g.Sum(t => Math.Max(0, t.DaysOverdue)));
343
+ return new TopRiskItem(g.Key, $"{overdue} overdue tasks", score, $"{score:0} risk", score >= 70 ? "rose" : "amber", "Capacity support may be needed");
344
+ })
345
+ .OrderByDescending(item => item.Score)
346
+ .Take(5)
347
+ .ToList();
348
+
349
+ private List<RiskMatrixPoint> OverdueRiskMatrix => allOverdue
350
+ .GroupBy(t => new { Severity = GetPrioritySeverity(t.PriorityName), Urgency = GetOverdueUrgency(t.DaysOverdue), t.ProjectName })
351
+ .Select(g => new RiskMatrixPoint(g.Key.ProjectName, g.Key.Severity, g.Key.Urgency, g.Count(), g.Key.Severity * g.Key.Urgency >= 9 ? "#ef4444" : "#f59e0b", $"{g.Count()} delayed task(s)"))
352
+ .ToList();
353
 
354
  protected override async Task OnInitializedAsync()
355
  {
 
436
  await LoadOverdueReport();
437
  }
438
 
439
+ private void SetView(bool chartView)
440
+ {
441
+ isChartView = chartView;
442
+ }
443
+
444
  private async Task HandlePageChanged(int page)
445
  {
446
  currentPage = page;
 
522
  3 => "Manager",
523
  _ => "None"
524
  };
525
+
526
+ private static int GetPrioritySeverity(string priorityName) => priorityName switch
527
+ {
528
+ "High" => 3,
529
+ "Medium" => 2,
530
+ _ => 1
531
+ };
532
+
533
+ private static int GetOverdueUrgency(int daysOverdue) => daysOverdue >= 8 ? 3 : daysOverdue >= 4 ? 2 : 1;
534
  }
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/ProjectProgressReport.razor CHANGED
@@ -152,14 +152,87 @@
152
 
153
  @if (isChartView)
154
  {
155
- <!-- Chart View: SVG Project Progress Bars -->
156
- <ChartCard Title="Project Health Chart" TitleClass="font-semibold text-slate-900 mb-4 font-sans text-md" BodyClass="mt-0">
157
- <ChildContent>
158
- <div class="max-w-2xl">
159
- <ProgressListChart Items="ProjectHealthItems" MaxValue="ProjectHealthMax" EmptyText="No data available." />
160
- </div>
161
- </ChildContent>
162
- </ChartCard>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  }
164
  else
165
  {
@@ -234,21 +307,57 @@
234
  private bool filterAssignToMe = false;
235
  private bool filterAssignToMyTeam = false;
236
 
237
- private List<ProgressListChartItem> ProjectHealthItems => allProjects
238
- .Select(proj =>
 
 
 
 
 
 
 
 
 
 
 
 
239
  {
240
- var value = Math.Min(Math.Max(proj.OpenIssues + proj.OverdueIssues + proj.BlockedIssues, 1) * 10, 100);
241
- var color = proj.ProjectHealth == "Critical" ? "#f43f5e" : proj.ProjectHealth == "At Risk" ? "#f59e0b" : "#10b981";
242
-
243
- return new ProgressListChartItem(
244
- proj.ProjectName,
245
- value,
246
- $"{proj.OpenIssues} open, {proj.OverdueIssues} overdue, {proj.BlockedIssues} blocked",
247
- color);
 
 
248
  })
249
  .ToList();
250
 
251
- private double ProjectHealthMax => Math.Max(100, ProjectHealthItems.Select(item => item.Value).DefaultIfEmpty(0).Max());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
 
253
  // Search and Pagination
254
  private string searchInput = "";
@@ -460,4 +569,34 @@
460
  "At Risk" => "bg-amber-50 text-amber-700",
461
  _ => "bg-emerald-50 text-emerald-700"
462
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  }
 
152
 
153
  @if (isChartView)
154
  {
155
+ <div class="grid grid-cols-1 gap-5 xl:grid-cols-12">
156
+ <div class="xl:col-span-6">
157
+ <RiskMatrixCard Title="Project health matrix" Subtitle="Progress health against issue risk." Points="ProjectHealthMatrix" Insight="Projects in the upper-right combine low progress with active issue pressure." />
158
+ </div>
159
+ <div class="xl:col-span-6">
160
+ <ReportChartCard Title="Completion vs risk" Subtitle="Each project plotted by completion and issue pressure." Insight="Lower completion with higher issue pressure should be reviewed first.">
161
+ @if (!ProjectQuadrantPoints.Any())
162
+ {
163
+ <div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">No project risk data available.</div>
164
+ }
165
+ else
166
+ {
167
+ <div class="space-y-3">
168
+ <div class="grid grid-cols-[44px_minmax(0,1fr)] gap-2">
169
+ <div class="flex items-center justify-center rounded-lg bg-slate-50 text-center text-[11px] font-semibold uppercase tracking-wider text-slate-500 [writing-mode:vertical-rl] rotate-180">
170
+ Risk
171
+ </div>
172
+ <div class="relative h-[280px] overflow-hidden rounded-xl border border-slate-200 bg-white">
173
+ <div class="absolute inset-0 grid grid-cols-2 grid-rows-2">
174
+ <div class="border-b border-r border-slate-200 bg-rose-50/50 p-3">
175
+ <div class="text-xs font-bold text-rose-700">Review now</div>
176
+ <div class="text-[11px] text-rose-600">Low completion · high risk</div>
177
+ </div>
178
+ <div class="border-b border-slate-200 bg-amber-50/50 p-3">
179
+ <div class="text-xs font-bold text-amber-700">Watch closely</div>
180
+ <div class="text-[11px] text-amber-600">High completion · high risk</div>
181
+ </div>
182
+ <div class="border-r border-slate-200 bg-blue-50/50 p-3">
183
+ <div class="text-xs font-bold text-blue-700">Recover plan</div>
184
+ <div class="text-[11px] text-blue-600">Low completion · low risk</div>
185
+ </div>
186
+ <div class="bg-emerald-50/50 p-3">
187
+ <div class="text-xs font-bold text-emerald-700">On track</div>
188
+ <div class="text-[11px] text-emerald-600">High completion · low risk</div>
189
+ </div>
190
+ </div>
191
+
192
+ @foreach (var point in ProjectQuadrantPoints)
193
+ {
194
+ <div class="absolute -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-sm transition-transform hover:scale-110"
195
+ title="@point.Tooltip"
196
+ style="left: @point.Left%; top: @point.Top%; width: 14px; height: 14px; background-color: @point.Color;">
197
+ </div>
198
+ }
199
+ </div>
200
+ </div>
201
+ <div class="grid grid-cols-[44px_minmax(0,1fr)] gap-2">
202
+ <div></div>
203
+ <div class="flex items-center justify-between text-[11px] font-semibold text-slate-400">
204
+ <span>0% complete</span>
205
+ <span>Completion</span>
206
+ <span>100%</span>
207
+ </div>
208
+ </div>
209
+ <div class="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2 text-[11px] text-slate-500">
210
+ <span class="font-semibold uppercase tracking-wider text-slate-400">Color</span>
211
+ <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-emerald-500"></span>Healthy</span>
212
+ <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-blue-500"></span>Watch</span>
213
+ <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-amber-500"></span>At risk</span>
214
+ <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-rose-500"></span>Critical</span>
215
+ </div>
216
+ </div>
217
+ }
218
+ </ReportChartCard>
219
+ </div>
220
+ <div class="xl:col-span-5">
221
+ <ReportChartCard Title="Top at-risk projects" Subtitle="Projects needing planning attention first." Insight="Risk blends overdue, blocked, open issues, and project health.">
222
+ <TopRiskList Items="AtRiskProjectItems" EmptyText="No at-risk projects in the current filter." />
223
+ </ReportChartCard>
224
+ </div>
225
+ <div class="xl:col-span-7">
226
+ <ReportChartCard Title="Timeline summary" Subtitle="Project schedule window with completion overlay." Insight="This gives the planning horizon without leaving the report.">
227
+ <TimelineChart Items="ProjectTimelineItems" WrapperClass="max-h-[340px] overflow-auto" />
228
+ </ReportChartCard>
229
+ </div>
230
+ <div class="xl:col-span-12">
231
+ <ReportChartCard Title="Open, overdue, and blocking issues by project" Subtitle="Issue composition across the project portfolio." Insight="Open work is normal; overdue and blocked work are the triage signal.">
232
+ <StackedProgressBar Segments="ProjectIssueSegments" AriaLabel="Project issue summary" />
233
+ </ReportChartCard>
234
+ </div>
235
+ </div>
236
  }
237
  else
238
  {
 
307
  private bool filterAssignToMe = false;
308
  private bool filterAssignToMyTeam = false;
309
 
310
+ private List<RiskMatrixPoint> ProjectHealthMatrix => allProjects
311
+ .Select(project => new RiskMatrixPoint(
312
+ project.ProjectName,
313
+ GetProjectSeverity(project),
314
+ GetProjectUrgency(project),
315
+ 1,
316
+ project.ProjectHealth == "Critical" ? "#ef4444" : project.IsAtRisk ? "#f59e0b" : "#3b82f6",
317
+ $"{project.Progress:0}% complete, {project.BlockedIssues} blocked, {project.OverdueIssues} overdue"))
318
+ .ToList();
319
+
320
+ private List<ProjectQuadrantPoint> ProjectQuadrantPoints => allProjects
321
+ .OrderByDescending(project => GetProjectRiskScore(project))
322
+ .Take(10)
323
+ .Select(project =>
324
  {
325
+ var risk = GetProjectRiskScore(project);
326
+ var left = Math.Clamp(project.Progress, 2, 98);
327
+ var top = Math.Clamp(100 - risk, 2, 98);
328
+ var color = project.ProjectHealth == "Critical"
329
+ ? "#ef4444"
330
+ : project.IsAtRisk
331
+ ? "#f59e0b"
332
+ : risk > 35 ? "#3b82f6" : "#10b981";
333
+ var tooltip = $"{project.ProjectName}\n{project.Progress:0}% complete\nRisk score: {risk:0}\n{project.BlockedIssues} blocked, {project.OverdueIssues} overdue, {project.OpenIssues} open";
334
+ return new ProjectQuadrantPoint(project.ProjectName, left, top, color, tooltip);
335
  })
336
  .ToList();
337
 
338
+ private List<TopRiskItem> AtRiskProjectItems => allProjects
339
+ .Select(project =>
340
+ {
341
+ var score = GetProjectRiskScore(project);
342
+ var tone = project.ProjectHealth == "Critical" || project.BlockedIssues > 0 ? "rose" : project.IsAtRisk ? "amber" : "blue";
343
+ return new TopRiskItem(project.ProjectName, $"{project.Progress:0}% complete · {project.OpenIssues} open issues", score, project.ProjectHealth, tone, $"{project.OverdueIssues} overdue · {project.BlockedIssues} blocked");
344
+ })
345
+ .OrderByDescending(item => item.Score)
346
+ .Take(5)
347
+ .ToList();
348
+
349
+ private List<TimelineChartItem> ProjectTimelineItems => allProjects
350
+ .OrderBy(project => project.StartDate)
351
+ .Take(8)
352
+ .Select(project => new TimelineChartItem(project.ProjectName, project.StartDate, project.EndDate, project.Progress, project.IsAtRisk ? "#f59e0b" : "#3b82f6"))
353
+ .ToList();
354
+
355
+ private List<StackedSegment> ProjectIssueSegments => new()
356
+ {
357
+ new("Open issues", allProjects.Sum(project => project.OpenIssues), "#3b82f6"),
358
+ new("Overdue issues", allProjects.Sum(project => project.OverdueIssues), "#ef4444"),
359
+ new("Blocked issues", allProjects.Sum(project => project.BlockedIssues), "#f59e0b")
360
+ };
361
 
362
  // Search and Pagination
363
  private string searchInput = "";
 
569
  "At Risk" => "bg-amber-50 text-amber-700",
570
  _ => "bg-emerald-50 text-emerald-700"
571
  };
572
+
573
+ private static double GetProjectRiskScore(ProjectProgressReportDto project)
574
+ {
575
+ var issueRisk = (project.OpenIssues * 8) + (project.OverdueIssues * 16) + (project.BlockedIssues * 22);
576
+ var progressRisk = Math.Max(0, 65 - project.Progress) * 0.45;
577
+ var healthRisk = project.ProjectHealth == "Critical" ? 25 : project.IsAtRisk ? 15 : 0;
578
+ return Math.Min(100, issueRisk + progressRisk + healthRisk);
579
+ }
580
+
581
+ private static int GetProjectSeverity(ProjectProgressReportDto project)
582
+ {
583
+ if (project.ProjectHealth == "Critical" || project.BlockedIssues > 0)
584
+ {
585
+ return 3;
586
+ }
587
+
588
+ return project.IsAtRisk || project.OverdueIssues > 0 ? 2 : 1;
589
+ }
590
+
591
+ private static int GetProjectUrgency(ProjectProgressReportDto project)
592
+ {
593
+ if (project.Progress < 35 || project.OverdueIssues > 2)
594
+ {
595
+ return 3;
596
+ }
597
+
598
+ return project.Progress < 70 || project.OpenIssues > 0 ? 2 : 1;
599
+ }
600
+
601
+ private sealed record ProjectQuadrantPoint(string Label, double Left, double Top, string Color, string Tooltip);
602
  }
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TaskReport.razor CHANGED
@@ -177,112 +177,30 @@
177
 
178
  @if (isChartView)
179
  {
180
- <!-- Chart View -->
181
- <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
182
- <!-- SVG Status Chart Card -->
183
- <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
184
- <h3 class="font-semibold text-slate-900 mb-4">Tasks by Status</h3>
185
- <div class="flex items-center justify-center h-64">
186
- @if (!FilteredList.Any())
187
- {
188
- <span class="text-sm text-slate-400">No data available</span>
189
- }
190
- else
191
- {
192
- var todo = FilteredList.Count(t => t.StatusId == AppTaskStatus.Todo);
193
- var inProgress = FilteredList.Count(t => t.StatusId == AppTaskStatus.InProgress);
194
- var done = FilteredList.Count(t => t.StatusId == AppTaskStatus.Done);
195
- var max = Math.Max(1, Math.Max(todo, Math.Max(inProgress, done)));
196
- var hTodo = (todo * 160) / max;
197
- var hInProgress = (inProgress * 160) / max;
198
- var hDone = (done * 160) / max;
199
-
200
- <svg class="w-full max-w-[320px]" height="220" viewBox="0 0 320 220">
201
- <g transform="translate(0, 180)">
202
- <!-- Axes -->
203
- <line x1="40" y1="0" x2="280" y2="0" stroke="#E5E7EB" stroke-width="2" />
204
- <!-- To Do Bar -->
205
- <rect x="60" y="-@hTodo" width="40" height="@hTodo" rx="4" fill="#E5E7EB" />
206
- <text x="80" y="20" font-size="12" fill="#6B7280" text-anchor="middle">To Do</text>
207
- <text x="80" y="-@(hTodo + 10)" font-size="12" font-weight="bold" fill="#111827" text-anchor="middle">@todo</text>
208
-
209
- <!-- In Progress Bar -->
210
- <rect x="140" y="-@hInProgress" width="40" height="@hInProgress" rx="4" fill="#06B6D4" />
211
- <text x="160" y="20" font-size="12" fill="#6B7280" text-anchor="middle">In Progress</text>
212
- <text x="160" y="-@(hInProgress + 10)" font-size="12" font-weight="bold" fill="#111827" text-anchor="middle">@inProgress</text>
213
-
214
- <!-- Done Bar -->
215
- <rect x="220" y="-@hDone" width="40" height="@hDone" rx="4" fill="#10B981" />
216
- <text x="240" y="20" font-size="12" fill="#6B7280" text-anchor="middle">Done</text>
217
- <text x="240" y="-@(hDone + 10)" font-size="12" font-weight="bold" fill="#111827" text-anchor="middle">@done</text>
218
- </g>
219
- </svg>
220
- }
221
- </div>
222
  </div>
223
-
224
- <!-- SVG Priority Chart Card -->
225
- <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
226
- <h3 class="font-semibold text-slate-900 mb-4">Tasks by Priority</h3>
227
- <div class="flex items-center justify-center h-64">
228
- @if (!FilteredList.Any())
229
- {
230
- <span class="text-sm text-slate-400">No data available</span>
231
- }
232
- else
233
- {
234
- var low = FilteredList.Count(t => t.PriorityId == TaskPriority.Low);
235
- var med = FilteredList.Count(t => t.PriorityId == TaskPriority.Medium);
236
- var high = FilteredList.Count(t => t.PriorityId == TaskPriority.High);
237
- var total = Math.Max(1, low + med + high);
238
- var pctLow = (low * 100) / total;
239
- var pctMed = (med * 100) / total;
240
- var pctHigh = (high * 100) / total;
241
-
242
- <div class="w-full space-y-4 max-w-[320px]">
243
- <div>
244
- <div class="flex justify-between text-xs font-semibold text-slate-600 mb-1">
245
- <span>High Priority</span>
246
- <span>@high tasks (@pctHigh%)</span>
247
- </div>
248
- <div class="h-3 rounded-full bg-slate-100 overflow-hidden">
249
- <div class="h-full bg-violet-600 rounded-full" style="width: @pctHigh%"></div>
250
- </div>
251
- </div>
252
- <div>
253
- <div class="flex justify-between text-xs font-semibold text-slate-600 mb-1">
254
- <span>Medium Priority</span>
255
- <span>@med tasks (@pctMed%)</span>
256
- </div>
257
- <div class="h-3 rounded-full bg-slate-100 overflow-hidden">
258
- <div class="h-full bg-violet-400 rounded-full" style="width: @pctMed%"></div>
259
- </div>
260
- </div>
261
- <div>
262
- <div class="flex justify-between text-xs font-semibold text-slate-600 mb-1">
263
- <span>Low Priority</span>
264
- <span>@low tasks (@pctLow%)</span>
265
- </div>
266
- <div class="h-3 rounded-full bg-slate-100 overflow-hidden">
267
- <div class="h-full bg-violet-200 rounded-full" style="width: @pctLow%"></div>
268
- </div>
269
- </div>
270
- </div>
271
- }
272
- </div>
273
  </div>
274
-
275
- <ChartCard Title="Tasks by Project" TitleClass="font-semibold text-slate-900 mb-4" BodyClass="mt-0">
276
- <ChildContent>
277
- <ProgressListChart Items="TasksByProjectItems" MaxValue="TasksByProjectMax" WrapperClass="space-y-3" />
278
- </ChildContent>
279
- </ChartCard>
280
-
281
- <ChartCard Title="Tasks with Most Issues" TitleClass="font-semibold text-slate-900 mb-4" BodyClass="mt-0">
282
- <ChildContent>
283
- <ProgressListChart Items="TasksWithMostIssuesItems" MaxValue="TasksWithMostIssuesMax" WrapperClass="space-y-3" />
284
- </ChildContent>
285
- </ChartCard>
286
  </div>
287
  }
288
  else
@@ -458,6 +376,85 @@
458
  $"{FilteredList.Count(t => t.OverdueIssues > 0)} tasks have overdue issues."
459
  };
460
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  private List<ProgressListChartItem> TasksByProjectItems => FilteredList
462
  .GroupBy(t => t.ProjectName)
463
  .OrderByDescending(g => g.Count())
 
177
 
178
  @if (isChartView)
179
  {
180
+ <div class="grid grid-cols-1 gap-5 xl:grid-cols-12">
181
+ <div class="xl:col-span-5">
182
+ <ReportChartCard Title="Task status mix" Subtitle="Where execution currently sits across the task lifecycle." Insight="@TaskStatusInsight">
183
+ <DonutStatusChart Items="TaskStatusItems" CenterLabel="tasks" />
184
+ </ReportChartCard>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  </div>
186
+ <div class="xl:col-span-4">
187
+ <ReportChartCard Title="Priority distribution" Subtitle="Planning pressure by task priority." Value="@HighPriorityCount.ToString()" ValueLabel="high priority" Insight="@PriorityInsight">
188
+ <StackedProgressBar Segments="PrioritySegments" AriaLabel="Task priority distribution" />
189
+ </ReportChartCard>
190
+ </div>
191
+ <div class="xl:col-span-3">
192
+ <TrendSparklineCard Title="Completion movement" Subtitle="Recently completed tasks by creation month." Value="@($"{CompletionRate:0}%")" ValueLabel="completion" Points="CompletionSparkline" Color="#10b981" Insight="@CompletionInsight" />
193
+ </div>
194
+ <div class="xl:col-span-6">
195
+ <ReportChartCard Title="Top project risk" Subtitle="Projects with the highest task and issue pressure." Insight="Score combines task volume, open issues, overdue issues, and critical task health.">
196
+ <TopRiskList Items="ProjectRiskItems" EmptyText="No project risk in this filtered set." />
197
+ </ReportChartCard>
198
+ </div>
199
+ <div class="xl:col-span-6">
200
+ <ReportChartCard Title="Tasks with most open issues" Subtitle="Execution blockers that may need issue cleanup before progress moves." Insight="Use this to decide where issue triage creates the fastest task movement.">
201
+ <TopRiskList Items="TaskIssueRiskItems" EmptyText="No open issue pressure in this filtered set." />
202
+ </ReportChartCard>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
204
  </div>
205
  }
206
  else
 
376
  $"{FilteredList.Count(t => t.OverdueIssues > 0)} tasks have overdue issues."
377
  };
378
 
379
+ private int FilteredTaskCount => FilteredList.Count();
380
+ private int CompletedTaskCount => FilteredList.Count(t => t.StatusId == AppTaskStatus.Done);
381
+ private int OpenTaskCount => FilteredTaskCount - CompletedTaskCount;
382
+ private int HighPriorityCount => FilteredList.Count(t => t.PriorityId == TaskPriority.High);
383
+ private double CompletionRate => FilteredTaskCount > 0 ? CompletedTaskCount / (double)FilteredTaskCount * 100 : 0;
384
+
385
+ private string TaskStatusInsight => OpenTaskCount > CompletedTaskCount
386
+ ? $"{OpenTaskCount} tasks are still moving through execution."
387
+ : "Completed work is leading the current filtered set.";
388
+
389
+ private string PriorityInsight => HighPriorityCount > 0
390
+ ? $"{HighPriorityCount} high-priority tasks should stay visible during planning review."
391
+ : "No high-priority tasks in the current filter.";
392
+
393
+ private string CompletionInsight => CompletionRate >= 70
394
+ ? "Completion is healthy for the current filter."
395
+ : "Completion has room to improve; inspect open issues and overdue work.";
396
+
397
+ private List<ReportChartItem> TaskStatusItems => new()
398
+ {
399
+ new("To Do", FilteredList.Count(t => t.StatusId == AppTaskStatus.Todo), "#94a3b8"),
400
+ new("In Progress", FilteredList.Count(t => t.StatusId == AppTaskStatus.InProgress), "#3b82f6"),
401
+ new("Done", CompletedTaskCount, "#10b981"),
402
+ new("Overdue", FilteredList.Count(t => t.DueDate.Date < DateTime.Today && t.StatusId != AppTaskStatus.Done), "#ef4444")
403
+ };
404
+
405
+ private List<StackedSegment> PrioritySegments => new()
406
+ {
407
+ new("High", HighPriorityCount, "#ef4444"),
408
+ new("Medium", FilteredList.Count(t => t.PriorityId == TaskPriority.Medium), "#f59e0b"),
409
+ new("Low", FilteredList.Count(t => t.PriorityId == TaskPriority.Low), "#3b82f6")
410
+ };
411
+
412
+ private List<SparklinePoint> CompletionSparkline => FilteredList
413
+ .GroupBy(t => new DateTime(t.CreatedAt.Year, t.CreatedAt.Month, 1))
414
+ .OrderBy(g => g.Key)
415
+ .TakeLast(8)
416
+ .Select(g => new SparklinePoint(g.Key.ToString("MMM"), g.Count(t => t.StatusId == AppTaskStatus.Done)))
417
+ .DefaultIfEmpty(new SparklinePoint(DateTime.Today.ToString("MMM"), CompletedTaskCount))
418
+ .ToList();
419
+
420
+ private List<TopRiskItem> ProjectRiskItems => FilteredList
421
+ .GroupBy(t => t.ProjectName)
422
+ .Select(g =>
423
+ {
424
+ 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));
425
+ var overdue = g.Sum(t => t.OverdueIssues);
426
+ var tone = overdue > 0 ? "rose" : score >= 45 ? "amber" : "blue";
427
+
428
+ return new TopRiskItem(
429
+ g.Key,
430
+ $"{g.Count()} tasks · {g.Sum(t => t.OpenIssues)} open issues",
431
+ score,
432
+ overdue > 0 ? $"{overdue} overdue" : $"{score:0} risk",
433
+ tone,
434
+ $"{g.Count(t => t.PriorityId == TaskPriority.High)} high-priority tasks");
435
+ })
436
+ .OrderByDescending(item => item.Score)
437
+ .Take(5)
438
+ .ToList();
439
+
440
+ private List<TopRiskItem> TaskIssueRiskItems => FilteredList
441
+ .OrderByDescending(t => (t.OpenIssues * 2) + (t.OverdueIssues * 3))
442
+ .Take(5)
443
+ .Select(t =>
444
+ {
445
+ var score = Math.Min(100, (t.OpenIssues * 20) + (t.OverdueIssues * 30));
446
+ var tone = t.OverdueIssues > 0 ? "rose" : t.OpenIssues > 0 ? "amber" : "emerald";
447
+
448
+ return new TopRiskItem(
449
+ t.Title,
450
+ t.ProjectName,
451
+ score,
452
+ $"{t.OpenIssues + t.OverdueIssues} issues",
453
+ tone,
454
+ $"{t.StatusName} · {t.PriorityName}");
455
+ })
456
+ .ToList();
457
+
458
  private List<ProgressListChartItem> TasksByProjectItems => FilteredList
459
  .GroupBy(t => t.ProjectName)
460
  .OrderByDescending(g => g.Count())
TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TimeTrackingReport.razor CHANGED
@@ -147,27 +147,42 @@
147
  {
148
  @if (isChartView)
149
  {
150
- <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
151
- <ChartCard Title="Estimated vs Actual" TitleClass="mb-4 text-sm font-semibold text-slate-900" BodyClass="mt-0">
152
- <ChildContent>
153
- <ProgressListChart Items="EstimatedVsActualItems" MaxValue="EstimatedVsActualMax" WrapperClass="space-y-3" />
154
- </ChildContent>
155
- </ChartCard>
156
- <ChartCard Title="Effort by Project" TitleClass="mb-4 text-sm font-semibold text-slate-900" BodyClass="mt-0">
157
- <ChildContent>
158
- <ProgressListChart Items="EffortByProjectItems" MaxValue="EffortByProjectMax" WrapperClass="space-y-3" />
159
- </ChildContent>
160
- </ChartCard>
161
- <ChartCard Title="Effort by Employee" TitleClass="mb-4 text-sm font-semibold text-slate-900" BodyClass="mt-0">
162
- <ChildContent>
163
- <ProgressListChart Items="EffortByEmployeeItems" MaxValue="EffortByEmployeeMax" WrapperClass="space-y-3" />
164
- </ChildContent>
165
- </ChartCard>
166
- <ChartCard Title="Estimate Accuracy" TitleClass="mb-4 text-sm font-semibold text-slate-900" BodyClass="mt-0">
167
- <ChildContent>
168
- <ProgressListChart Items="EstimateAccuracyItems" MaxValue="EstimateAccuracyMax" WrapperClass="space-y-3" />
169
- </ChildContent>
170
- </ChartCard>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  </div>
172
  }
173
  else
@@ -411,6 +426,61 @@
411
 
412
  private static decimal GetVariance(IssueDto issue) => (issue.ActualHours ?? 0) - (issue.EstimatedHours ?? 0);
413
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  private List<ProgressListChartItem> EstimatedVsActualItems => new[]
415
  {
416
  new ProgressListChartItem("Estimated", (double)totalHours, $"{totalHours:N1}", "#7c3aed"),
 
147
  {
148
  @if (isChartView)
149
  {
150
+ <div class="grid grid-cols-1 gap-5 xl:grid-cols-12">
151
+ <div class="xl:col-span-5">
152
+ <MetricComparisonCard Title="Estimated vs actual effort"
153
+ Subtitle="Are plans matching delivery effort?"
154
+ PrimaryLabel="Actual"
155
+ PrimaryValue="@($"{actualHours:N1}h")"
156
+ SecondaryLabel="Estimated"
157
+ SecondaryValue="@($"{totalHours:N1}h")"
158
+ Segments="EffortComparisonSegments"
159
+ Insight="@EffortVarianceInsight" />
160
+ </div>
161
+ <div class="xl:col-span-3">
162
+ <RadialMetricCard Title="Utilization gauge"
163
+ Subtitle="Actual hours as a share of estimated effort."
164
+ Percent="@utilizationPercent"
165
+ Value="@($"{varianceHours:N1}h")"
166
+ ValueLabel="variance"
167
+ CenterLabel="used"
168
+ Color="@UtilizationColor"
169
+ Insight="@UtilizationInsight" />
170
+ </div>
171
+ <div class="xl:col-span-4">
172
+ <ReportChartCard Title="Estimate accuracy" Subtitle="How many items stayed within estimate?" Value="@OnOrUnderEstimateCount.ToString()" ValueLabel="on / under" Insight="@EstimateAccuracyInsight">
173
+ <DonutStatusChart Items="EstimateAccuracyDonutItems" CenterLabel="issues" />
174
+ </ReportChartCard>
175
+ </div>
176
+ <div class="xl:col-span-7">
177
+ <ReportChartCard Title="Effort by project" Subtitle="Actual hours distributed across active projects." Insight="Use this to spot projects consuming more delivery capacity than expected.">
178
+ <StackedProgressBar Segments="ProjectEffortSegments" AriaLabel="Effort by project" />
179
+ </ReportChartCard>
180
+ </div>
181
+ <div class="xl:col-span-5">
182
+ <ReportChartCard Title="Effort leaderboard" Subtitle="People carrying the most tracked effort." Insight="Names highlight capacity allocation, not performance judgement.">
183
+ <TopRiskList Items="EmployeeEffortLeaders" EmptyText="No employee effort data available." />
184
+ </ReportChartCard>
185
+ </div>
186
  </div>
187
  }
188
  else
 
426
 
427
  private static decimal GetVariance(IssueDto issue) => (issue.ActualHours ?? 0) - (issue.EstimatedHours ?? 0);
428
 
429
+ private int OnOrUnderEstimateCount => FilteredIssues.Count(i => (i.ActualHours ?? 0) <= (i.EstimatedHours ?? 0));
430
+ private int OverEstimateCount => FilteredIssues.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0));
431
+ private string UtilizationColor => utilizationPercent > 115 ? "#ef4444" : utilizationPercent > 95 ? "#f59e0b" : "#10b981";
432
+ private string EffortVarianceInsight => varianceHours > 0
433
+ ? $"Actual effort is {varianceHours:N1}h above estimate."
434
+ : $"Actual effort is {Math.Abs(varianceHours):N1}h under estimate.";
435
+ private string UtilizationInsight => utilizationPercent > 115
436
+ ? "Utilization is above plan; review estimates and blockers."
437
+ : utilizationPercent < 70 ? "Tracked actual effort is below plan; confirm work is being logged." : "Utilization is close to the planning range.";
438
+ private string EstimateAccuracyInsight => OverEstimateCount > OnOrUnderEstimateCount
439
+ ? "More issues are exceeding estimate than staying within it."
440
+ : "Most issues are within or under their estimates.";
441
+
442
+ private List<StackedSegment> EffortComparisonSegments => new()
443
+ {
444
+ new("Actual", (double)Math.Max(0, actualHours), "#3b82f6"),
445
+ new("Remaining estimate", (double)Math.Max(0, totalHours - actualHours), "#dbeafe")
446
+ };
447
+
448
+ private List<ReportChartItem> EstimateAccuracyDonutItems => new()
449
+ {
450
+ new("On / Under", OnOrUnderEstimateCount, "#10b981"),
451
+ new("Over", OverEstimateCount, "#ef4444")
452
+ };
453
+
454
+ private List<StackedSegment> ProjectEffortSegments => FilteredIssues
455
+ .GroupBy(i => i.ProjectName)
456
+ .OrderByDescending(g => g.Sum(i => i.ActualHours ?? 0))
457
+ .Take(6)
458
+ .Select((g, index) => new StackedSegment(g.Key, (double)g.Sum(i => i.ActualHours ?? 0), ChartPalette[index % ChartPalette.Length]))
459
+ .ToList();
460
+
461
+ private List<TopRiskItem> EmployeeEffortLeaders => FilteredIssues
462
+ .GroupBy(i => i.AssignedToName ?? "Unassigned")
463
+ .Select(g =>
464
+ {
465
+ var actual = g.Sum(i => i.ActualHours ?? 0);
466
+ var estimated = g.Sum(i => i.EstimatedHours ?? 0);
467
+ var score = estimated > 0 ? Math.Min(100, (double)(actual / estimated) * 100) : Math.Min(100, (double)actual * 10);
468
+ var tone = score > 115 ? "rose" : score > 90 ? "amber" : "blue";
469
+
470
+ return new TopRiskItem(
471
+ g.Key,
472
+ $"{actual:N1}h actual · {estimated:N1}h estimated",
473
+ score,
474
+ $"{g.Count()} issues",
475
+ tone,
476
+ $"{g.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0))} over estimate");
477
+ })
478
+ .OrderByDescending(item => item.Score)
479
+ .Take(5)
480
+ .ToList();
481
+
482
+ private static readonly string[] ChartPalette = ["#7c3aed", "#3b82f6", "#06b6d4", "#10b981", "#f59e0b", "#ef4444"];
483
+
484
  private List<ProgressListChartItem> EstimatedVsActualItems => new[]
485
  {
486
  new ProgressListChartItem("Estimated", (double)totalHours, $"{totalHours:N1}", "#7c3aed"),