Spaces:
Sleeping
Sleeping
User commited on
Commit ·
3b3dce7
1
Parent(s): 917b070
update: Adjust all chart views on reports
Browse files- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/AgingBucketChart.razor +32 -0
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/DonutStatusChart.razor +64 -0
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/FunnelChart.razor +64 -0
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/MetricComparisonCard.razor +26 -0
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/RadialMetricCard.razor +37 -0
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportChartCard.razor +43 -0
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportChartModels.cs +13 -0
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/RiskMatrixCard.razor +129 -0
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/StackedProgressBar.razor +36 -0
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/TopRiskList.razor +52 -0
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/TrendSparklineCard.razor +50 -0
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/WorkloadHeatmap.razor +55 -0
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/EmployeeReport.razor +73 -15
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/IssueReport.razor +84 -20
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/OverdueTasksReport.razor +95 -2
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/ProjectProgressReport.razor +158 -19
- TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TaskReport.razor +101 -104
- 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 |
-
<
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
<
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
}
|
| 123 |
else
|
| 124 |
{
|
|
@@ -201,15 +216,52 @@
|
|
| 201 |
private bool filterAssignToMe = false;
|
| 202 |
private bool filterAssignToMyTeam = false;
|
| 203 |
|
| 204 |
-
private List<
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
.ToList();
|
| 211 |
|
| 212 |
-
private
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 288 |
{
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
|
|
|
| 293 |
};
|
| 294 |
|
| 295 |
-
private
|
| 296 |
{
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
| 303 |
{
|
| 304 |
-
var
|
| 305 |
-
|
| 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 & 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 & 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 |
-
<
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
<
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
}
|
| 164 |
else
|
| 165 |
{
|
|
@@ -234,21 +307,57 @@
|
|
| 234 |
private bool filterAssignToMe = false;
|
| 235 |
private bool filterAssignToMyTeam = false;
|
| 236 |
|
| 237 |
-
private List<
|
| 238 |
-
.Select(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
{
|
| 240 |
-
var
|
| 241 |
-
var
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
| 248 |
})
|
| 249 |
.ToList();
|
| 250 |
|
| 251 |
-
private
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
<
|
| 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 |
-
|
| 225 |
-
|
| 226 |
-
<
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 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
|
| 151 |
-
<
|
| 152 |
-
<
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
</
|
| 161 |
-
<
|
| 162 |
-
<
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"),
|