@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 @AppLocalization.Text("report.projectProgressTitle", "Project Progress Report")

@AppLocalization.Text("report.projectProgressHeading", "Project Progress")

@AppLocalization.Text("report.projectProgressDesc", "View project health using both planning tasks and daily execution issues.")

@if (filterPeriod == "custom") {
}
@if (isLoading) {
@AppLocalization.Text("common.loading", "Loading data...")
} else {

@AppLocalization.Text("dashboard.activeProjects", "Total Active Projects")

@AppLocalization.Number(totalActiveProjects)

@AppLocalization.Text("report.aheadOfSchedule", "Ahead of Schedule")

@AppLocalization.Number(aheadOfScheduleCount)

@AppLocalization.Text("dashboard.atRisk", "At Risk")

@AppLocalization.Number(atRiskCount)

@if (isChartView) {
@if (!ProjectQuadrantPoints.Any()) {
@AppLocalization.Text("report.noAtRiskProjects", "No project risk data available.")
} else {
@AppLocalization.Text("common.risk", "Risk")
@AppLocalization.Text("report.reviewNow", "Review now")
@AppLocalization.Text("report.lowCompletionHighRisk", "Low completion · high risk")
@AppLocalization.Text("report.watchClosely", "Watch closely")
@AppLocalization.Text("report.highCompletionHighRisk", "High completion · high risk")
@AppLocalization.Text("report.recoverPlan", "Recover plan")
@AppLocalization.Text("report.lowCompletionLowRisk", "Low completion · low risk")
@AppLocalization.Text("report.onTrack", "On track")
@AppLocalization.Text("report.highCompletionLowRisk", "High completion · low risk")
@foreach (var point in ProjectQuadrantPoints) {
}
@AppLocalization.Text("report.percentComplete", "0% complete").Replace("{percent}", AppLocalization.Number(0)) @AppLocalization.Text("report.completion", "Completion") @AppLocalization.Text("report.percentComplete", "100% complete").Replace("{percent}", AppLocalization.Number(100))
@AppLocalization.Text("report.color", "Color") @AppLocalization.Text("report.healthGood", "Healthy") @AppLocalization.Text("report.healthWatch", "Watch") @AppLocalization.Text("report.healthAtRisk", "At risk") @AppLocalization.Text("report.healthCritical", "Critical")
}
} else {
@if (pagedProjects.Any()) { @foreach (var entry in pagedProjects.Select((proj, index) => (proj, index))) { var proj = entry.proj; } } else { }
@AppLocalization.Text("common.index", "No.") @AppLocalization.Text("project.projectName", "Project Name") @AppLocalization.Text("common.startDate", "Start Date") @AppLocalization.Text("common.endDate", "End Date") @AppLocalization.Text("report.openIssues", "Open Issues") @AppLocalization.Text("report.blockedOverdue", "Blocked / Overdue") @AppLocalization.Text("report.health", "Health") @AppLocalization.Text("report.lastActivity", "Last Activity")
@AppLocalization.Number((currentPage - 1) * pageSize + entry.index + 1) @Content.GetText(proj.ProjectName, proj.ProjectNameMy) @DisplayFormats.Date(proj.StartDate) @DisplayFormats.Date(proj.EndDate) @AppLocalization.Number(proj.OpenIssues) @AppLocalization.Number(proj.BlockedIssues) / @AppLocalization.Number(proj.OverdueIssues) @GetProjectHealthLabel(proj.ProjectHealth) @DisplayFormats.Date(proj.LastActivity)

@AppLocalization.Text("report.noProjectData", "No project data found matching criteria.")

} }
@code { private bool isLoading = true; private bool isChartView = false; private List allProjects = new(); private List pagedProjects = new(); private PagedResult? 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 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 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 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 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 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(); 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>>(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(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>(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 { $"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); }