User
add: language switch
8c88a89
Raw
History Blame Contribute Delete
33.4 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" 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">Daily (Today)</option>
<option value="monthly">Monthly (This Month)</option>
<option value="yearly">Yearly (This Year)</option>
<option value="custom">Custom Range</option>
</select>
</div>
@if (filterPeriod == "custom")
{
<div>
<label class="block text-xs font-medium text-slate-500 mb-1.5">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">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> Search
</button>
<button @onclick="ResetAllFilters" 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="rotate-ccw" class="h-4 w-4 mr-2 text-slate-500"></i> Reset
</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">Total Active Projects</p>
<p class="mt-1 text-3xl font-bold text-violet-700">@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">Ahead of Schedule</p>
<p class="mt-1 text-3xl font-bold text-emerald-700">@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">At Risk</p>
<p class="mt-1 text-3xl font-bold text-rose-700">@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="Project health matrix" Subtitle="Progress health against issue risk." Points="ProjectHealthMatrix" Insight="Projects in the upper-right combine low progress with active issue pressure." />
</div>
<div class="xl:col-span-6">
<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.">
@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">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">
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">Review now</div>
<div class="text-[11px] text-rose-600">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">Watch closely</div>
<div class="text-[11px] text-amber-600">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">Recover plan</div>
<div class="text-[11px] text-blue-600">Low completion · low risk</div>
</div>
<div class="bg-emerald-50/50 p-3">
<div class="text-xs font-bold text-emerald-700">On track</div>
<div class="text-[11px] text-emerald-600">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>0% complete</span>
<span>Completion</span>
<span>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">Color</span>
<span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-emerald-500"></span>Healthy</span>
<span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-blue-500"></span>Watch</span>
<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>
<span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-rose-500"></span>Critical</span>
</div>
</div>
}
</ReportChartCard>
</div>
<div class="xl:col-span-5">
<ReportChartCard Title="Top at-risk projects" Subtitle="Projects needing planning attention first." Insight="Risk blends overdue, blocked, open issues, and project health.">
<TopRiskList Items="AtRiskProjectItems" EmptyText="No at-risk projects in the current filter." />
</ReportChartCard>
</div>
<div class="xl:col-span-7">
<ReportChartCard Title="Timeline summary" Subtitle="Project schedule window with completion overlay." Insight="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="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.">
<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">No.</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Project Name</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Start Date</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">End Date</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Open Issues</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Blocked / Overdue</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Health</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">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">@((currentPage - 1) * pageSize + entry.index + 1)</td>
<td class="px-6 py-4 text-sm font-semibold text-slate-900">@proj.ProjectName</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">@proj.OpenIssues</td>
<td class="px-6 py-4 text-sm font-medium"><span class="text-amber-600">@proj.BlockedIssues</span> / <span class="text-rose-600">@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)">@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">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",
$"{project.Progress:0}% complete, {project.BlockedIssues} blocked, {project.OverdueIssues} 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 tooltip = $"{project.ProjectName}\n{project.Progress:0}% complete\nRisk score: {risk:0}\n{project.BlockedIssues} blocked, {project.OverdueIssues} overdue, {project.OpenIssues} open";
return new ProjectQuadrantPoint(project.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(project.ProjectName, $"{project.Progress:0}% complete · {project.OpenIssues} open issues", score, project.ProjectHealth, tone, $"{project.OverdueIssues} overdue · {project.BlockedIssues} blocked");
})
.OrderByDescending(item => item.Score)
.Take(5)
.ToList();
private List<TimelineChartItem> ProjectTimelineItems => allProjects
.OrderBy(project => project.StartDate)
.Take(8)
.Select(project => new TimelineChartItem(project.ProjectName, project.StartDate, project.EndDate, project.Progress, project.IsAtRisk ? "#f59e0b" : "#3b82f6"))
.ToList();
private List<StackedSegment> ProjectIssueSegments => new()
{
new("Open issues", allProjects.Sum(project => project.OpenIssues), "#3b82f6"),
new("Overdue issues", allProjects.Sum(project => project.OverdueIssues), "#ef4444"),
new("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?[]
{
proj.ProjectName,
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[]
{
proj.ProjectName,
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 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);
}