From 891801c73d5bb1023a23d6bac79ec2a27767a817 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 28 Sep 2026 11:45:05 +0200 Subject: [PATCH] Light projects page refactor --- .../projects/ProjectTimelineGantt.tsx | 12 +- .../src/pages/ProjectsPage.tsx | 278 +++++++++++++++--- 2 files changed, 245 insertions(+), 45 deletions(-) diff --git a/PostFundManagement.ConsoleUI/src/components/projects/ProjectTimelineGantt.tsx b/PostFundManagement.ConsoleUI/src/components/projects/ProjectTimelineGantt.tsx index e23bd55..a3b18f8 100644 --- a/PostFundManagement.ConsoleUI/src/components/projects/ProjectTimelineGantt.tsx +++ b/PostFundManagement.ConsoleUI/src/components/projects/ProjectTimelineGantt.tsx @@ -175,7 +175,7 @@ export const ProjectTimelineGantt: React.FC = ({ {/* Timeline Visualization Surface */} -
+
{/* Background vertical grid lines */}
{timelineData.ticks.map((tick, idx) => ( @@ -189,13 +189,13 @@ export const ProjectTimelineGantt: React.FC = ({ {/* Today marker */} {timelineData.isTodayInRange && (
- + Today -
+
)}
@@ -217,11 +217,11 @@ export const ProjectTimelineGantt: React.FC = ({
{timelineData.sortedMilestones.length === 0 ? ( -
+
No contractual milestones defined yet. Click "Add / Manage Milestones" to establish deliverables.
) : ( -
+
{/* Milestone connecting base line */}
diff --git a/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx index 0720aec..16cab21 100644 --- a/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx @@ -5,7 +5,6 @@ import { FolderKanban, Plus, Search, - Filter, RotateCcw, ChevronDown, ChevronRight, @@ -17,12 +16,10 @@ import { Calendar, Clock, TrendingUp, - Layers, FileCheck, - Building2, - CheckCircle2, + AlertTriangle, AlertCircle, - BarChart3, + CheckCircle2, SlidersHorizontal, } from 'lucide-react'; import { @@ -35,6 +32,8 @@ import { updateProjectStatus, } from '../services/api'; import { HelpTooltip } from '../components/ui/HelpTooltip'; +import { ExportButton } from '../components/ui/ExportButton'; +import { ExportColumn } from '../utils/exportUtils'; import { useToast } from '../components/ui/Toast'; import { useTableSort, SortableTh } from '../utils/tableSort'; import { @@ -62,6 +61,89 @@ import { ProjectActivitiesModal } from '../components/projects/ProjectActivities import { ProjectIndicatorsModal } from '../components/projects/ProjectIndicatorsModal'; import { ProjectRisksModal } from '../components/projects/ProjectRisksModal'; +export interface ProjectScheduleRisk { + isAtRisk: boolean; + isOverdue: boolean; + daysRemaining: number | null; + reason: string; + badgeClass: string; + iconType: 'critical' | 'warning' | 'on_track' | 'pending'; +} + +export const evaluateProjectScheduleRisk = ( + project: Project, + milestoneStats?: { total: number; verified: number } +): ProjectScheduleRisk => { + const hasStarted = Boolean(project.startedAt); + const targetDateStr = project.endedAt; + + if (!targetDateStr) { + return { + isAtRisk: false, + isOverdue: false, + daysRemaining: null, + reason: 'No contractual target completion date configured yet.', + badgeClass: 'bg-slate-800 text-slate-400 border-slate-700/80', + iconType: 'pending', + }; + } + + const now = Date.now(); + const targetTime = new Date(targetDateStr).getTime(); + const diffDays = Math.ceil((targetTime - now) / (1000 * 60 * 60 * 24)); + + const totalMilestones = milestoneStats?.total || 0; + const verifiedMilestones = milestoneStats?.verified || 0; + const isMilestonesIncomplete = totalMilestones === 0 || verifiedMilestones < totalMilestones; + + if (hasStarted && isMilestonesIncomplete) { + if (diffDays < 0) { + return { + isAtRisk: true, + isOverdue: true, + daysRemaining: diffDays, + reason: `Target completion deadline passed ${Math.abs(diffDays)} day${ + Math.abs(diffDays) === 1 ? '' : 's' + } ago with ${totalMilestones - verifiedMilestones} unverified milestone deliverable(s) remaining.`, + badgeClass: 'bg-rose-500/15 text-rose-400 border-rose-500/40 font-bold animate-pulse', + iconType: 'critical', + }; + } + if (diffDays <= 30) { + return { + isAtRisk: true, + isOverdue: false, + daysRemaining: diffDays, + reason: `Project is ${diffDays} day${ + diffDays === 1 ? '' : 's' + } away from target due date with only ${verifiedMilestones} of ${totalMilestones} milestones verified.`, + badgeClass: 'bg-amber-500/15 text-amber-300 border-amber-500/40 font-semibold', + iconType: 'warning', + }; + } + } + + if (totalMilestones > 0 && verifiedMilestones === totalMilestones) { + return { + isAtRisk: false, + isOverdue: false, + daysRemaining: diffDays, + reason: `All ${totalMilestones} contractual milestone deliverables verified and approved.`, + badgeClass: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30', + iconType: 'on_track', + }; + } + + return { + isAtRisk: false, + isOverdue: false, + daysRemaining: diffDays, + reason: `${diffDays} days remaining until target completion deadline.`, + badgeClass: 'bg-slate-800/80 text-slate-300 border-slate-700/80', + iconType: 'on_track', + }; +}; + export const ProjectsPage: React.FC = () => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); @@ -185,6 +267,39 @@ export const ProjectsPage: React.FC = () => { }); }, [projects, searchTerm, selectedSectorFilter, selectedStatusFilter]); + // Export Columns Configuration + const projectExportColumns: ExportColumn[] = useMemo(() => [ + { header: 'Project ID', accessor: (p) => `#${p.id}` }, + { header: 'Project Name', accessor: (p) => p.name || 'Untitled' }, + { + header: 'Parent Programme', + accessor: (p) => programmeMap.get(p.programmeId)?.name || `Prog #${p.programmeId}`, + }, + { header: 'Industry Sector', accessor: (p) => getIndustrySectorLabel(p.sector) }, + { header: 'Project Type', accessor: (p) => getProjectTypeLabel(p.projectType) }, + { header: 'Start Date', accessor: (p) => formatDate(p.startedAt) }, + { header: 'Target End Date', accessor: (p) => formatDate(p.endedAt) }, + { + header: 'Schedule Risk Alert', + accessor: (p) => { + const stats = milestonesByProject.get(p.id); + const risk = evaluateProjectScheduleRisk(p, stats); + return risk.isAtRisk ? (risk.isOverdue ? `OVERDUE: ${risk.reason}` : `AT RISK: ${risk.reason}`) : 'ON TRACK'; + }, + }, + { + header: 'Milestone Progress', + accessor: (p) => { + const stats = milestonesByProject.get(p.id); + if (!stats || stats.total === 0) return '0 / 0 (0%)'; + const pct = Math.round((stats.verified / stats.total) * 100); + return `${stats.verified} / ${stats.total} (${pct}%)`; + }, + }, + { header: 'Approval Status', accessor: (p) => getApprovalStatusBadge(p.status).label }, + { header: 'Created By', accessor: (p) => getUserEmail(p.createdBy) }, + ], [programmeMap, milestonesByProject]); + // Table Sorting const customExtractors = { name: (p: Project) => (p.name || '').toLowerCase(), @@ -198,11 +313,18 @@ export const ProjectsPage: React.FC = () => { if (!p.startedAt || !p.endedAt) return 0; return new Date(p.endedAt).getTime() - new Date(p.startedAt).getTime(); }, + risk: (p: Project) => { + const stats = milestonesByProject.get(p.id); + const risk = evaluateProjectScheduleRisk(p, stats); + if (risk.isOverdue) return 1; + if (risk.isAtRisk) return 2; + return 3; + }, }; const { sortedData: sortedProjects, sortColumn, sortDirection, handleSort } = useTableSort( filteredProjects, - 'name', + 'risk', 'asc', customExtractors ); @@ -277,7 +399,12 @@ export const ProjectsPage: React.FC = () => { const verifiedMilestonesTotal = allMilestones.filter((m) => m.status === ApprovalStatus.Approved).length; const milestoneCompletionRate = allMilestones.length > 0 ? Math.round((verifiedMilestonesTotal / allMilestones.length) * 100) : 0; - const highRiskCount = allRisks.filter((r) => r.likelihood * r.impact >= 12).length; + const atRiskProjectsCount = useMemo(() => { + return projects.filter((p) => { + const stats = milestonesByProject.get(p.id); + return evaluateProjectScheduleRisk(p, stats).isAtRisk; + }).length; + }, [projects, milestonesByProject]); return (
@@ -312,6 +439,14 @@ export const ProjectsPage: React.FC = () => {
+ +
+
+
+ Schedule Risk Alerts + 0 ? 'text-amber-400 animate-pulse' : 'text-slate-500'}`} /> +
+
+ 0 ? 'text-amber-400' : 'text-slate-300'}`}> + {atRiskProjectsCount} + + Imminent / Overdue +
+

Due with unverified deliverables

+
+
M&E Indicators @@ -362,19 +511,7 @@ export const ProjectsPage: React.FC = () => { {allIndicators.length} Tracked KPIs
-

Impact & performance results

-
- -
-
- Risk Profile - -
-
- {allRisks.length} - ({highRiskCount} High/Critical) -
-

ISO 31000 risk registers

+

Results & impact metrics

@@ -446,25 +583,31 @@ export const ProjectsPage: React.FC = () => {
- {/* Actions: Reset & Refresh */} - + {/* Clear Filter Button */} + {(searchTerm || selectedProgrammeFilter !== 'all' || selectedSectorFilter !== 'all' || selectedStatusFilter !== 'all') && ( + + )} + {/* Single Refresh Button */}
@@ -551,6 +694,17 @@ export const ProjectsPage: React.FC = () => { WBS Progress + +
+ Schedule Risk + +
+
Status @@ -560,7 +714,7 @@ export const ProjectsPage: React.FC = () => { {loadingProjects ? ( - +
Loading project directory... @@ -569,7 +723,7 @@ export const ProjectsPage: React.FC = () => { ) : sortedProjects.length === 0 ? ( - +

No matching projects found

@@ -591,13 +745,13 @@ export const ProjectsPage: React.FC = () => { ) : ( sortedProjects.map((p) => { const isExpanded = expandedRowIds.has(p.id); - const statusBadge = getApprovalStatusBadge(p.status); const prog = programmeMap.get(p.programmeId); const milestoneStats = milestonesByProject.get(p.id) || { total: 0, verified: 0 }; const progressPct = milestoneStats.total > 0 ? Math.round((milestoneStats.verified / milestoneStats.total) * 100) : 0; + const scheduleRisk = evaluateProjectScheduleRisk(p, milestoneStats); return ( @@ -684,10 +838,10 @@ export const ProjectsPage: React.FC = () => { {/* Milestone Progress */} -
+
= 100 ? 'text-emerald-400' : 'text-blue-400'}> - {progressPct}% Verified + {progressPct}% ({milestoneStats.verified}/{milestoneStats.total}) @@ -706,6 +860,52 @@ export const ProjectsPage: React.FC = () => {
+ {/* Schedule Risk Column with Icon & Tooltip */} + e.stopPropagation()}> +
+ {scheduleRisk.isOverdue ? ( + + + Overdue + + ) : scheduleRisk.isAtRisk ? ( + + + At Risk ({scheduleRisk.daysRemaining}d) + + ) : scheduleRisk.iconType === 'on_track' ? ( + + + On Track + + ) : ( + + + Scheduled + + )} + + +
+ + {/* Governance Status */} e.stopPropagation()}>
@@ -791,7 +991,7 @@ export const ProjectsPage: React.FC = () => { {/* Expanded View Content */} {isExpanded && ( - + handleOpenMilestones(p)} -- 2.47.3