Spaces:
Running
Running
task-tracking-system / TaskTrackingSystem.WebApp /Components /Pages /Features /Reports /ProjectProgressReport.razor
| @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); | |
| } | |