User
Deploy localized dashboard fixes
3dd5c9a
Raw History Blame Contribute Delete
38.2 kB
@page "/reports/projects"
@using Microsoft.AspNetCore.Authorization
@using TaskTrackingSystem.Shared
@using TaskTrackingSystem.Shared.Models.Project
@using TaskTrackingSystem.Shared.Models.Task
@using TaskTrackingSystem.Shared.Models.Report
@using System.Text
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@attribute [Authorize]
@inject ApiClientService ApiClient
@inject IJSRuntime JS
@inject AuthenticationStateProvider AuthStateProvider
@inject MenuAuthorizationService MenuAuthorization
<PageTitle>@AppLocalization.Text("report.projectProgressTitle", "Project Progress Report")</PageTitle>
<div class="space-y-6">
<!-- Page Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.Text("report.projectProgressHeading", "Project Progress")</h2>
<p class="text-slate-500 mt-1">@AppLocalization.Text("report.projectProgressDesc", "View project health using both planning tasks and daily execution issues.")</p>
</div>
<div class="flex items-center space-x-3">
<div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
<button @onclick="() => SetView(false)" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold @(!isChartView ? "bg-violet-600 text-white" : "text-slate-600 hover:text-slate-900") transition-colors">
<i data-lucide="list" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.listView", "List View")
</button>
<button @onclick="() => SetView(true)" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold @(isChartView ? "bg-violet-600 text-white" : "text-slate-600 hover:text-slate-900") transition-colors">
<i data-lucide="bar-chart-3" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.chartView", "Chart View")
</button>
</div>
<button @onclick="DownloadExcel" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm">
<i data-lucide="download" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportExcel", "Export Excel")
</button>
<button @onclick="DownloadPdf" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm">
<i data-lucide="file-text" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportPdf", "Export PDF")
</button>
</div>
</div>
<!-- Filters -->
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-end gap-4">
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("report.searchProject", "Search Project")</label>
<input @bind="searchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchByName", "Search by name...")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" />
</div>
<div>
<label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("report.periodProjectStart", "Period (Project Start Date)")</label>
<select @bind="filterPeriod" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all">
<option value="all">@AppLocalization.Text("common.all", "All Time")</option>
<option value="daily">@AppLocalization.Text("report.daily", "Daily (Today)")</option>
<option value="monthly">@AppLocalization.Text("report.monthly", "Monthly (This Month)")</option>
<option value="yearly">@AppLocalization.Text("report.yearly", "Yearly (This Year)")</option>
<option value="custom">@AppLocalization.Text("report.customRange", "Custom Range")</option>
</select>
</div>
@if (filterPeriod == "custom")
{
<div>
<label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.startDate", "Start Date")</label>
<input @bind="filterStartDate" type="date" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" />
</div>
<div>
<label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.endDate", "End Date")</label>
<input @bind="filterEndDate" type="date" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" />
</div>
}
<div>
<label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("report.taskStatusCompletion", "Task Status Completion")</label>
<select @bind="filterStatus" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all">
<option value="0">@AppLocalization.Text("common.allTasks", "All Tasks")</option>
<option value="1">@AppLocalization.StatusLabel(AppTaskStatus.Todo)</option>
<option value="2">@AppLocalization.StatusLabel(AppTaskStatus.InProgress)</option>
<option value="3">@AppLocalization.StatusLabel(AppTaskStatus.Done)</option>
<option value="overdue">@AppLocalization.Text("status.overdue", "Overdue")</option>
</select>
</div>
<button @onclick="ApplyAllFilters" class="inline-flex items-center rounded-lg bg-violet-600 px-5 py-2 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm">
<i data-lucide="search" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("action.search", "Search")
</button>
<button @onclick="ResetAllFilters" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2 text-slate-700 hover:bg-slate-50 transition-colors shadow-sm">
<i data-lucide="rotate-ccw" class="h-4 w-4 text-slate-500"></i>
</button>
<div class="flex flex-wrap items-center gap-6 w-full border-t border-slate-100 pt-3 mt-1">
<label class="flex items-center space-x-2 text-sm font-medium text-slate-700 cursor-pointer select-none">
<input type="checkbox" @bind="filterAssignToMe" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" />
<span>@AppLocalization.Text("report.assignedToMe", "Assigned to me")</span>
</label>
<label class="flex items-center space-x-2 text-sm font-medium text-slate-700 cursor-pointer select-none">
<input type="checkbox" @bind="filterAssignToMyTeam" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" />
<span>@AppLocalization.Text("report.assignedToMyTeam", "Assigned to my team")</span>
</label>
</div>
</div>
</div>
<!-- Content Area -->
@if (isLoading)
{
<div class="flex items-center justify-center py-20">
<div class="flex items-center space-x-3 text-slate-500">
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
<span class="text-sm font-medium">@AppLocalization.Text("common.loading", "Loading data...")</span>
</div>
</div>
}
else
{
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<!-- Total Active Projects Card -->
<div class="rounded-xl border border-violet-100 bg-violet-50 p-5 shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-violet-500 font-sans">@AppLocalization.Text("dashboard.activeProjects", "Total Active Projects")</p>
<p class="mt-1 text-3xl font-bold text-violet-700">@AppLocalization.Number(totalActiveProjects)</p>
</div>
<span class="flex h-11 w-11 items-center justify-center rounded-xl bg-violet-100">
<i data-lucide="folder" class="h-6 w-6 text-violet-600"></i>
</span>
</div>
</div>
<!-- Ahead of Schedule Card -->
<div class="rounded-xl border border-emerald-100 bg-emerald-50 p-5 shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-emerald-500 font-sans">@AppLocalization.Text("report.aheadOfSchedule", "Ahead of Schedule")</p>
<p class="mt-1 text-3xl font-bold text-emerald-700">@AppLocalization.Number(aheadOfScheduleCount)</p>
</div>
<span class="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-100">
<i data-lucide="trending-up" class="h-6 w-6 text-emerald-600"></i>
</span>
</div>
</div>
<!-- At Risk Card -->
<div class="rounded-xl border border-rose-100 bg-rose-50 p-5 shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-rose-500 font-sans">@AppLocalization.Text("dashboard.atRisk", "At Risk")</p>
<p class="mt-1 text-3xl font-bold text-rose-700">@AppLocalization.Number(atRiskCount)</p>
</div>
<span class="flex h-11 w-11 items-center justify-center rounded-xl bg-rose-100">
<i data-lucide="alert-triangle" class="h-6 w-6 text-rose-600"></i>
</span>
</div>
</div>
</div>
@if (isChartView)
{
<div class="grid grid-cols-1 gap-5 xl:grid-cols-12">
<div class="xl:col-span-6">
<RiskMatrixCard Title="@AppLocalization.Text("report.projectHealthMatrix", "Project health matrix")" Subtitle="@AppLocalization.Text("report.projectHealthMatrixDesc", "Progress health against issue risk.")" Points="ProjectHealthMatrix" Insight="@AppLocalization.Text("report.projectHealthMatrixInsight", "Projects in the upper-right combine low progress with active issue pressure.")" />
</div>
<div class="xl:col-span-6">
<ReportChartCard Title="@AppLocalization.Text("report.completionVsRisk", "Completion vs risk")" Subtitle="@AppLocalization.Text("report.completionVsRiskDesc", "Each project plotted by completion and issue pressure.")" Insight="@AppLocalization.Text("report.completionVsRiskInsight", "Lower completion with higher issue pressure should be reviewed first.")">
@if (!ProjectQuadrantPoints.Any())
{
<div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@AppLocalization.Text("report.noAtRiskProjects", "No project risk data available.")</div>
}
else
{
<div class="space-y-3">
<div class="grid grid-cols-[44px_minmax(0,1fr)] gap-2">
<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">
@AppLocalization.Text("common.risk", "Risk")
</div>
<div class="relative h-[280px] overflow-hidden rounded-xl border border-slate-200 bg-white">
<div class="absolute inset-0 grid grid-cols-2 grid-rows-2">
<div class="border-b border-r border-slate-200 bg-rose-50/50 p-3">
<div class="text-xs font-bold text-rose-700">@AppLocalization.Text("report.reviewNow", "Review now")</div>
<div class="text-[11px] text-rose-600">@AppLocalization.Text("report.lowCompletionHighRisk", "Low completion · high risk")</div>
</div>
<div class="border-b border-slate-200 bg-amber-50/50 p-3">
<div class="text-xs font-bold text-amber-700">@AppLocalization.Text("report.watchClosely", "Watch closely")</div>
<div class="text-[11px] text-amber-600">@AppLocalization.Text("report.highCompletionHighRisk", "High completion · high risk")</div>
</div>
<div class="border-r border-slate-200 bg-blue-50/50 p-3">
<div class="text-xs font-bold text-blue-700">@AppLocalization.Text("report.recoverPlan", "Recover plan")</div>
<div class="text-[11px] text-blue-600">@AppLocalization.Text("report.lowCompletionLowRisk", "Low completion · low risk")</div>
</div>
<div class="bg-emerald-50/50 p-3">
<div class="text-xs font-bold text-emerald-700">@AppLocalization.Text("report.onTrack", "On track")</div>
<div class="text-[11px] text-emerald-600">@AppLocalization.Text("report.highCompletionLowRisk", "High completion · low risk")</div>
</div>
</div>
@foreach (var point in ProjectQuadrantPoints)
{
<div class="absolute -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-sm transition-transform hover:scale-110"
title="@point.Tooltip"
style="left: @point.Left%; top: @point.Top%; width: 14px; height: 14px; background-color: @point.Color;">
</div>
}
</div>
</div>
<div class="grid grid-cols-[44px_minmax(0,1fr)] gap-2">
<div></div>
<div class="flex items-center justify-between text-[11px] font-semibold text-slate-400">
<span>@AppLocalization.Text("report.percentComplete", "0% complete").Replace("{percent}", AppLocalization.Number(0))</span>
<span>@AppLocalization.Text("report.completion", "Completion")</span>
<span>@AppLocalization.Text("report.percentComplete", "100% complete").Replace("{percent}", AppLocalization.Number(100))</span>
</div>
</div>
<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">
<span class="font-semibold uppercase tracking-wider text-slate-400">@AppLocalization.Text("report.color", "Color")</span>
<span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-emerald-500"></span>@AppLocalization.Text("report.healthGood", "Healthy")</span>
<span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-blue-500"></span>@AppLocalization.Text("report.healthWatch", "Watch")</span>
<span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-amber-500"></span>@AppLocalization.Text("report.healthAtRisk", "At risk")</span>
<span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-rose-500"></span>@AppLocalization.Text("report.healthCritical", "Critical")</span>
</div>
</div>
}
</ReportChartCard>
</div>
<div class="xl:col-span-5">
<ReportChartCard Title="@AppLocalization.Text("report.topAtRiskProjects", "Top at-risk projects")" Subtitle="@AppLocalization.Text("report.topAtRiskProjectsDesc", "Projects needing planning attention first.")" Insight="@AppLocalization.Text("report.topAtRiskProjectsInsight", "Risk blends overdue, blocked, open issues, and project health.")">
<TopRiskList Items="AtRiskProjectItems" EmptyText="@AppLocalization.Text("report.noAtRiskProjects", "No at-risk projects in the current filter.")" />
</ReportChartCard>
</div>
<div class="xl:col-span-7">
<ReportChartCard Title="@AppLocalization.Text("report.timelineSummary", "Timeline summary")" Subtitle="@AppLocalization.Text("report.timelineSummaryDesc", "Project schedule window with completion overlay.")" Insight="@AppLocalization.Text("report.timelineSummaryInsight", "This gives the planning horizon without leaving the report.")">
<TimelineChart Items="ProjectTimelineItems" WrapperClass="max-h-[340px] overflow-auto" />
</ReportChartCard>
</div>
<div class="xl:col-span-12">
<ReportChartCard Title="@AppLocalization.Text("report.issuesCompositionByProject", "Open, overdue, and blocking issues by project")" Subtitle="@AppLocalization.Text("report.issuesCompositionByProjectDesc", "Issue composition across the project portfolio.")" Insight="@AppLocalization.Text("report.issuesCompositionByProjectInsight", "Open work is normal; overdue and blocked work are the triage signal.")">
<StackedProgressBar Segments="ProjectIssueSegments" AriaLabel="Project issue summary" />
</ReportChartCard>
</div>
</div>
}
else
{
<!-- List View -->
<div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-slate-100 bg-slate-50/60">
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.index", "No.")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("project.projectName", "Project Name")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.startDate", "Start Date")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.endDate", "End Date")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.openIssues", "Open Issues")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.blockedOverdue", "Blocked / Overdue")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.health", "Health")</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.lastActivity", "Last Activity")</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@if (pagedProjects.Any())
{
@foreach (var entry in pagedProjects.Select((proj, index) => (proj, index)))
{
var proj = entry.proj;
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="px-6 py-4 text-sm font-semibold text-slate-500">@AppLocalization.Number((currentPage - 1) * pageSize + entry.index + 1)</td>
<td class="px-6 py-4 text-sm font-semibold text-slate-900">@Content.GetText(proj.ProjectName, proj.ProjectNameMy)</td>
<td class="px-6 py-4 text-sm text-slate-600">@DisplayFormats.Date(proj.StartDate)</td>
<td class="px-6 py-4 text-sm text-slate-600">@DisplayFormats.Date(proj.EndDate)</td>
<td class="px-6 py-4 text-sm text-slate-900">@AppLocalization.Number(proj.OpenIssues)</td>
<td class="px-6 py-4 text-sm font-medium"><span class="text-amber-600">@AppLocalization.Number(proj.BlockedIssues)</span> / <span class="text-rose-600">@AppLocalization.Number(proj.OverdueIssues)</span></td>
<td class="px-6 py-4"><span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold @GetProjectHealthBadge(proj.ProjectHealth)">@GetProjectHealthLabel(proj.ProjectHealth)</span></td>
<td class="px-6 py-4 text-sm text-slate-600">@DisplayFormats.Date(proj.LastActivity)</td>
</tr>
}
}
else
{
<tr>
<td colspan="8" class="px-6 py-12 text-center">
<p class="text-sm text-slate-400">@AppLocalization.Text("report.noProjectData", "No project data found matching criteria.")</p>
</td>
</tr>
}
</tbody>
</table>
<!-- Pagination -->
<Pagination CurrentPage="currentPage"
TotalPages="TotalPages"
PageSize="pageSize"
TotalCount="TotalCount"
OnPageChanged="HandlePageChanged"
OnPageSizeChanged="HandlePageSizeChanged" />
</div>
}
}
</div>
@code {
private bool isLoading = true;
private bool isChartView = false;
private List<ProjectProgressReportDto> allProjects = new();
private List<ProjectProgressReportDto> pagedProjects = new();
private PagedResult<ProjectProgressReportDto>? reportPage;
// Filters
private string filterPeriod = "all";
private DateTime? filterStartDate;
private DateTime? filterEndDate;
private string filterStatus = "0";
private bool filterAssignToMe = false;
private bool filterAssignToMyTeam = false;
private List<RiskMatrixPoint> ProjectHealthMatrix => allProjects
.Select(project => new RiskMatrixPoint(
project.ProjectName,
GetProjectSeverity(project),
GetProjectUrgency(project),
1,
project.ProjectHealth == "Critical" ? "#ef4444" : project.IsAtRisk ? "#f59e0b" : "#3b82f6",
$"{AppLocalization.Text("report.percentComplete", "{percent}% complete").Replace("{percent}", AppLocalization.Number(project.Progress, "0"))}, {AppLocalization.Number(project.BlockedIssues)} {AppLocalization.Text("common.blocked", "blocked")}, {AppLocalization.Number(project.OverdueIssues)} {AppLocalization.Text("common.overdue", "overdue")}"))
.ToList();
private List<ProjectQuadrantPoint> ProjectQuadrantPoints => allProjects
.OrderByDescending(project => GetProjectRiskScore(project))
.Take(10)
.Select(project =>
{
var risk = GetProjectRiskScore(project);
var left = Math.Clamp(project.Progress, 2, 98);
var top = Math.Clamp(100 - risk, 2, 98);
var color = project.ProjectHealth == "Critical"
? "#ef4444"
: project.IsAtRisk
? "#f59e0b"
: risk > 35 ? "#3b82f6" : "#10b981";
var projectName = Content.GetText(project.ProjectName, project.ProjectNameMy);
var tooltip = $"{projectName}\n{AppLocalization.Text("report.percentComplete", "{percent}% complete").Replace("{percent}", AppLocalization.Number(project.Progress, "0"))}\n{AppLocalization.Text("report.riskScore", "Risk score")}: {AppLocalization.Number(risk, "0")}\n{AppLocalization.Number(project.BlockedIssues)} {AppLocalization.Text("common.blocked", "blocked")}, {AppLocalization.Number(project.OverdueIssues)} {AppLocalization.Text("common.overdue", "overdue")}, {AppLocalization.Number(project.OpenIssues)} {AppLocalization.Text("report.open", "open")}";
return new ProjectQuadrantPoint(projectName, left, top, color, tooltip);
})
.ToList();
private List<TopRiskItem> AtRiskProjectItems => allProjects
.Select(project =>
{
var score = GetProjectRiskScore(project);
var tone = project.ProjectHealth == "Critical" || project.BlockedIssues > 0 ? "rose" : project.IsAtRisk ? "amber" : "blue";
return new TopRiskItem(Content.GetText(project.ProjectName, project.ProjectNameMy), $"{AppLocalization.Text("report.percentComplete", "{percent}% complete").Replace("{percent}", AppLocalization.Number(project.Progress, "0"))} · {AppLocalization.Number(project.OpenIssues)} {AppLocalization.Text("report.openIssuesSegment", "open issues")}", score, GetProjectHealthLabel(project.ProjectHealth), tone, $"{AppLocalization.Number(project.OverdueIssues)} {AppLocalization.Text("status.overdue", "overdue")} · {AppLocalization.Number(project.BlockedIssues)} {AppLocalization.Text("status.blocked", "blocked")}");
})
.OrderByDescending(item => item.Score)
.Take(5)
.ToList();
private List<TimelineChartItem> ProjectTimelineItems => allProjects
.OrderBy(project => project.StartDate)
.Take(8)
.Select(project => new TimelineChartItem(Content.GetText(project.ProjectName, project.ProjectNameMy), project.StartDate, project.EndDate, project.Progress, project.IsAtRisk ? "#f59e0b" : "#3b82f6"))
.ToList();
private List<StackedSegment> ProjectIssueSegments => new()
{
new(AppLocalization.Text("report.openIssuesSegment", "Open issues"), allProjects.Sum(project => project.OpenIssues), "#3b82f6"),
new(AppLocalization.Text("report.overdueIssuesSegment", "Overdue issues"), allProjects.Sum(project => project.OverdueIssues), "#ef4444"),
new(AppLocalization.Text("report.blockedIssuesSegment", "Blocked issues"), allProjects.Sum(project => project.BlockedIssues), "#f59e0b")
};
// Search and Pagination
private string searchInput = "";
private string searchQuery = "";
private int currentPage = 1;
private int pageSize = 10;
// Card stats
private int totalActiveProjects = 0;
private int aheadOfScheduleCount = 0;
private int atRiskCount = 0;
private int TotalPages => reportPage?.TotalPages ?? 0;
private int TotalCount => reportPage?.TotalCount ?? 0;
protected override async Task OnInitializedAsync()
{
await LoadProjectReportData();
isLoading = false;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JS.InvokeVoidAsync("initIcons");
}
private void SetView(bool chartView)
{
isChartView = chartView;
}
private async Task ApplyAllFilters()
{
ApplyPeriodPreset();
searchQuery = searchInput;
currentPage = 1;
await LoadProjectReportData();
}
private async Task ResetAllFilters()
{
searchInput = "";
searchQuery = "";
filterPeriod = "all";
filterStartDate = null;
filterEndDate = null;
filterStatus = "0";
filterAssignToMe = false;
filterAssignToMyTeam = false;
currentPage = 1;
await LoadProjectReportData();
}
private async Task HandlePageChanged(int page)
{
currentPage = page;
await LoadProjectReportData();
}
private async Task HandlePageSizeChanged(int size)
{
pageSize = size;
currentPage = 1;
await LoadProjectReportData();
}
private void ApplyPeriodPreset()
{
var today = DateTime.Today;
if (filterPeriod == "daily")
{
filterStartDate = today;
filterEndDate = today;
}
else if (filterPeriod == "monthly")
{
filterStartDate = new DateTime(today.Year, today.Month, 1);
filterEndDate = new DateTime(today.Year, today.Month, DateTime.DaysInMonth(today.Year, today.Month));
}
else if (filterPeriod == "yearly")
{
filterStartDate = new DateTime(today.Year, 1, 1);
filterEndDate = new DateTime(today.Year, 12, 31);
}
else
{
filterStartDate = null;
filterEndDate = null;
}
}
private async Task LoadProjectReportData()
{
var client = ApiClient.CreateClient();
try
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(searchQuery)) parts.Add($"search={Uri.EscapeDataString(searchQuery)}");
if (filterStartDate.HasValue) parts.Add($"startDate={filterStartDate:yyyy-MM-dd}");
if (filterEndDate.HasValue) parts.Add($"endDate={filterEndDate:yyyy-MM-dd}");
if (!string.IsNullOrWhiteSpace(filterStatus) && filterStatus != "0") parts.Add($"status={Uri.EscapeDataString(filterStatus)}");
if (filterAssignToMe) parts.Add("assignedToMe=true");
if (filterAssignToMyTeam) parts.Add("assignedToMyTeam=true");
// 1. Fetch full matching list for chart view, cards summary, and exports
var fullUrl = "Report/project-progress" + (parts.Count > 0 ? "?" + string.Join("&", parts) : "");
var fullResult = await client.GetFromJsonAsync<Result<List<ProjectProgressReportDto>>>(fullUrl, Serialization.CaseInsensitive);
if (fullResult != null && fullResult.IsSuccess && fullResult.Value != null)
{
allProjects = fullResult.Value;
totalActiveProjects = allProjects.Count;
aheadOfScheduleCount = allProjects.Count(m => m.IsAhead);
atRiskCount = allProjects.Count(m => m.IsAtRisk);
}
// 2. Fetch paged list for table pagination
var pagedParts = new List<string>(parts);
pagedParts.Add($"page={currentPage}");
pagedParts.Add($"limit={pageSize}");
var pagedUrl = "Report/project-progress" + (pagedParts.Count > 0 ? "?" + string.Join("&", pagedParts) : "");
var pagedResult = await client.GetFromJsonAsync<PagedResult<ProjectProgressReportDto>>(pagedUrl, Serialization.CaseInsensitive);
if (pagedResult != null)
{
reportPage = pagedResult;
pagedProjects = pagedResult.Items.ToList();
currentPage = pagedResult.Page;
pageSize = pagedResult.PageSize;
}
}
catch (Exception ex)
{
Console.WriteLine($"Error loading project progress report: {ex.Message}");
}
}
private async Task DownloadExcel()
{
var rows = allProjects.Select(proj => new object?[]
{
Content.GetText(proj.ProjectName, proj.ProjectNameMy),
proj.StartDate,
proj.EndDate,
proj.OpenIssues,
proj.OverdueIssues,
proj.BlockedIssues,
proj.ProjectHealth,
proj.ActualHours
});
var workbookBytes = SpreadsheetReportBuilder.BuildTableWorkbook(
"Project Progress",
"Project Progress Report",
new[] { "Project", "Start Date", "End Date", "Open Issues", "Overdue Issues", "Blocked Issues", "Health", "Actual Hours" },
rows,
new[]
{
$"Search: {(string.IsNullOrWhiteSpace(searchQuery) ? "All projects" : searchQuery)}",
$"Period: {filterPeriod}",
$"Status: {filterStatus switch { "0" => "All", "1" => "To Do", "2" => "In Progress", "3" => "Done", "overdue" => "Overdue", _ => filterStatus }}",
$"Generated: {DisplayFormats.Date(DateTime.Today)}"
});
await JS.InvokeVoidAsync(
"downloadFileFromBase64",
$"ProjectProgressReport_{DateTime.Today:yyyyMMdd}.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
Convert.ToBase64String(workbookBytes));
}
private async Task DownloadPdf()
{
var summaryLines = new List<string>
{
$"Search: {(string.IsNullOrWhiteSpace(searchQuery) ? "All projects" : searchQuery)}",
$"Period: {filterPeriod}",
$"Status: {filterStatus switch { "0" => "All", "1" => "To Do", "2" => "In Progress", "3" => "Done", "overdue" => "Overdue", _ => filterStatus }}",
$"Generated: {DisplayFormats.Date(DateTime.Today)}"
};
var rows = allProjects.Select(proj => new[]
{
Content.GetText(proj.ProjectName, proj.ProjectNameMy),
DisplayFormats.Date(proj.StartDate),
DisplayFormats.Date(proj.EndDate),
proj.OpenIssues.ToString(),
proj.OverdueIssues.ToString(),
proj.BlockedIssues.ToString(),
proj.ProjectHealth,
proj.ActualHours.ToString("N1")
});
var pdfBytes = SimplePdfReportBuilder.BuildTableReport(
"Project Progress Report",
summaryLines,
new[] { "Project", "Start Date", "End Date", "Open", "Overdue", "Blocked", "Health", "Hours" },
rows);
await JS.InvokeVoidAsync(
"downloadFileFromBase64",
$"ProjectProgressReport_{DateTime.Today:yyyyMMdd}.pdf",
"application/pdf",
Convert.ToBase64String(pdfBytes));
}
private static string GetProjectHealthBadge(string health) => health switch
{
"Critical" => "bg-rose-50 text-rose-700",
"At Risk" => "bg-amber-50 text-amber-700",
_ => "bg-emerald-50 text-emerald-700"
};
private static string GetProjectHealthLabel(string health) => health switch
{
"Critical" => AppLocalization.Text("report.healthCritical", "Critical"),
"At Risk" => AppLocalization.Text("report.healthAtRisk", "At Risk"),
_ => AppLocalization.Text("report.healthGood", "Healthy")
};
private static double GetProjectRiskScore(ProjectProgressReportDto project)
{
var issueRisk = (project.OpenIssues * 8) + (project.OverdueIssues * 16) + (project.BlockedIssues * 22);
var progressRisk = Math.Max(0, 65 - project.Progress) * 0.45;
var healthRisk = project.ProjectHealth == "Critical" ? 25 : project.IsAtRisk ? 15 : 0;
return Math.Min(100, issueRisk + progressRisk + healthRisk);
}
private static int GetProjectSeverity(ProjectProgressReportDto project)
{
if (project.ProjectHealth == "Critical" || project.BlockedIssues > 0)
{
return 3;
}
return project.IsAtRisk || project.OverdueIssues > 0 ? 2 : 1;
}
private static int GetProjectUrgency(ProjectProgressReportDto project)
{
if (project.Progress < 35 || project.OverdueIssues > 2)
{
return 3;
}
return project.Progress < 70 || project.OpenIssues > 0 ? 2 : 1;
}
private sealed record ProjectQuadrantPoint(string Label, double Left, double Top, string Color, string Tooltip);
}