From 0261a6093ec61fe989d81600e816e65f3d3c8f10 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 22 Oct 2026 09:41:41 +0200 Subject: [PATCH] Refactored project view into modal Elevated toast message priority --- .../projects/ProjectExpandedRow.tsx | 26 +- .../projects/ProjectTimelineGantt.tsx | 863 ++++++++++++------ .../components/projects/ProjectViewModal.tsx | 694 ++++++++++++++ .../src/components/ui/Modal.tsx | 10 +- .../src/components/ui/Toast.tsx | 32 +- .../src/pages/ProjectsPage.tsx | 270 +++--- 6 files changed, 1454 insertions(+), 441 deletions(-) create mode 100644 PostFundManagement.ConsoleUI/src/components/projects/ProjectViewModal.tsx diff --git a/PostFundManagement.ConsoleUI/src/components/projects/ProjectExpandedRow.tsx b/PostFundManagement.ConsoleUI/src/components/projects/ProjectExpandedRow.tsx index 9baa856..ef40fb6 100644 --- a/PostFundManagement.ConsoleUI/src/components/projects/ProjectExpandedRow.tsx +++ b/PostFundManagement.ConsoleUI/src/components/projects/ProjectExpandedRow.tsx @@ -91,21 +91,21 @@ export const ProjectExpandedRow: React.FC = ({ const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0); return ( -
+
{/* Project Meta Bar */} -
+
- + Project #{project.id} Summary - + Owner / Created by: {getUserEmail(project.createdBy)} {approvedBudgetTotal > 0 && ( <> • - + Approved Budget: {formatZAR(approvedBudgetTotal)} @@ -114,10 +114,10 @@ export const ProjectExpandedRow: React.FC = ({

{project.description}

-
+
Review Evidence Queue @@ -125,7 +125,7 @@ export const ProjectExpandedRow: React.FC = ({ + ) : project.startedAt ? ( + + ) : null} + + {/* Jump to Today */} + {timelineData.isTodayInRange && ( + + )} + + {/* Jump to Last Milestone */} + {timelineData.lastMilestone ? ( + + ) : project.endedAt ? ( + + ) : null} +
+ + {/* Zoom Level Selector */} +
+ + + +
+ + {/* Scroll Shift Buttons in Top Header */} +
+ +
- {/* Main Gantt Timeline Track Container */} -
- {/* Month Scale / Ruler */} -
- {timelineData.ticks.map((tick, idx) => ( -
- - {tick.label} - - -
- ))} + {/* Milestone & Activity Legend & Milestone Span Pill */} +
+
+ + + Verified Milestone + + + + Pending Review + + + + Contractual Deliverable + + + + Work Activity +
- {/* Timeline Visualization Surface */} -
- {/* Background vertical grid lines */} -
- {timelineData.ticks.map((tick, idx) => ( -
- ))} - - {/* Today marker */} - {timelineData.isTodayInRange && ( -
- - Today - -
-
- )} + {/* First & Last Milestone Span Pill (Requirement 5) */} + {timelineData.firstMilestone && timelineData.lastMilestone && ( +
+ 1st: {formatDate(timelineData.firstMilestone.dueAt)} + + Final: {formatDate(timelineData.lastMilestone.dueAt)}
+ )} +
- {/* Layer 1: Milestones Track */} -
-
- - - Contractual Deliverables & Milestones ({milestones.length}) - - + {/* Scrollable Timeline Visualization Container */} +
+ {/* Scrollable Viewport */} +
+ {/* Inner Canvas with expansive width */} +
+ {/* Background vertical month grid lines */} +
+ {timelineData.ticks.map((tick, idx) => ( +
+ ))} + + {/* Project Start Line Marker */} + {timelineData.projectStartPercent !== null && ( +
+ + Start: {formatDate(project.startedAt)} + +
+
+ )} + + {/* Project Target End Line Marker */} + {timelineData.projectEndPercent !== null && ( +
+ + End: {formatDate(project.endedAt)} + +
+
+ )} + + {/* Today line marker */} + {timelineData.isTodayInRange && ( +
+ + Today + +
+
+ )}
- {timelineData.sortedMilestones.length === 0 ? ( -
- No contractual milestones defined yet. Click "Add / Manage Milestones" to establish deliverables. -
- ) : ( -
- {/* Milestone connecting base line */} -
- - {/* Milestone Pins */} - {timelineData.sortedMilestones.map((m, idx) => { - const mTime = new Date(m.dueAt || 0).getTime(); - const pos = timelineData.getPercent(mTime); - const isApproved = m.status === ApprovalStatus.Approved; - const isPending = m.status === ApprovalStatus.Pending; - const priBadge = getPriorityBadge(m.priority); - - return ( -
+ {timelineData.ticks.map((tick, idx) => ( +
+
+ - {/* Flag Marker */} + {tick.label} + + {tick.isStartOfYear && ( + + {tick.yearLabel} + + )} +
+ +
+ ))} +
+ + {/* Layer 1: Contractual Deliverables & Milestones Track */} +
+
+ + + Contractual Deliverables & Milestones ({milestones.length}) + + +
+ + {timelineData.sortedMilestones.length === 0 ? ( +
+ No contractual milestones defined yet. Click "Add / Manage Milestones" to establish deliverables. +
+ ) : ( +
+ {/* Base connecting line */} +
+ {/* Visual progress overlay */} +
+
+ + {/* Milestone Pins */} + {timelineData.sortedMilestones.map((m, idx) => { + const mTime = new Date(m.dueAt || 0).getTime(); + const pos = timelineData.getPercent(mTime); + const isApproved = m.status === ApprovalStatus.Approved; + const isPending = m.status === ApprovalStatus.Pending; + const isFirst = timelineData.firstMilestone?.id === m.id; + const isLast = timelineData.lastMilestone?.id === m.id && timelineData.sortedMilestones.length > 1; + const priBadge = getPriorityBadge(m.priority); + + return (
- -
+ {/* First / Last Milestone Distinctive Badge Indicator (Requirement 5) */} + {isFirst && ( +
+ + + 1st Milestone + +
+ )} + {isLast && !isFirst && ( +
+ + + Final Close-out + +
+ )} - {/* Brief Pin Label */} -
- - {m.name} - - - {formatDate(m.dueAt)} - -
- - {/* Tooltip on Hover */} -
-
- Milestone #{m.id} - - {priBadge.label} - + {/* Milestone Flag Icon Bubble */} +
+
-

{m.name}

-
- - + + {/* Deliverable Label */} +
+ + {m.name} + + {formatDate(m.dueAt)} - - {isApproved ? 'Verified' : 'Pending'} - +
+ + {/* Rich Hover Card */} +
+
+ + Milestone #{m.id} + + + {priBadge.label} + +
+

{m.name}

+
+ + + {formatDate(m.dueAt)} + + + {isApproved ? '✓ Verified' : '● Pending Review'} + +
-
- ); - })} -
- )} -
- - {/* Layer 2: Activities Track */} -
-
- - - Work Breakdown Activities ({activities.length}) - - + ); + })} +
+ )}
- {timelineData.sortedActivities.length === 0 ? ( -
- No activities recorded in work breakdown structure. + {/* Layer 2: Work Breakdown Activities Track */} +
+
+ + + Work Breakdown Activities ({activities.length}) + +
- ) : ( -
- {timelineData.sortedActivities.slice(0, 4).map((act, idx) => { - const aTime = new Date(act.performedAt || project.startedAt || Date.now()).getTime(); - const pos = timelineData.getPercent(aTime); - return ( -
-
- {act.title} - {act.category || 'Operations'} -
+ {timelineData.sortedActivities.length === 0 ? ( +
+ No activities recorded in work breakdown structure. +
+ ) : ( +
+ {timelineData.sortedActivities.slice(0, 5).map((act, idx) => { + const aTime = new Date(act.performedAt || project.startedAt || Date.now()).getTime(); + const pos = timelineData.getPercent(aTime); -
-
-
- - {formatZAR(act.amount)} + return ( +
+
+ + {act.title} + + {act.category || 'Operations'} +
+ +
+
+
+ + {formatZAR(act.amount)} +
+
+ +
+ {formatDate(act.performedAt)}
+ ); + })} -
- {formatDate(act.performedAt)} -
+ {activities.length > 5 && ( +
+
- ); - })} - - {activities.length > 4 && ( -
- -
- )} -
- )} -
-
-
- - {/* Bottom Summary KPI Bar */} -
-
- Project Duration - - {timelineData.totalDurationDays} Days - - - {formatDate(project.startedAt)} → {formatDate(project.endedAt)} - -
- -
- Milestone Progress -
- {milestoneProgress}% - ({verifiedMilestonesCount}/{milestones.length}) -
-
-
+ )} +
+ )} +
-
- Work Breakdown Cost - - {formatZAR(totalActivityBudget)} - - {activities.length} total tasks + {/* BOTTOM NAVIGATION ARROWS (EXACTLY WHERE RED ARROWS WERE IN USER'S SCREENSHOT) */} + {/* Bottom Left Navigation Arrow Button */} +
+
-
- Governance State -
- {(() => { - const b = getApprovalStatusBadge(project.status); - return ( - - {b.label} - - ); - })()} -
- Statutory Clearance -
- - {/* Total Value of Approved Project Budgets Widget */} -
- Approved Budget Value - - {formatZAR(approvedBudgetTotal)} - - - {approvedBudgetsCount > 0 - ? `${approvedBudgetsCount} approved budget line${approvedBudgetsCount === 1 ? '' : 's'}` - : 'Allocated Project Budget'} - + {/* Bottom Right Navigation Arrow Button */} +
+
diff --git a/PostFundManagement.ConsoleUI/src/components/projects/ProjectViewModal.tsx b/PostFundManagement.ConsoleUI/src/components/projects/ProjectViewModal.tsx new file mode 100644 index 0000000..d2e6f6c --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/components/projects/ProjectViewModal.tsx @@ -0,0 +1,694 @@ +import React, { useMemo } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import { + FolderKanban, + Flag, + Briefcase, + Target, + ShieldAlert, + FileCheck, + Edit2, + Calendar, + Clock, + Coins, + ChevronRight, + TrendingUp, + AlertTriangle, + AlertCircle, + CheckCircle2, + RotateCcw, + Sparkles, + ExternalLink, + SlidersHorizontal, +} from 'lucide-react'; +import { Modal } from '../ui/Modal'; +import { HelpTooltip } from '../ui/HelpTooltip'; +import { useToast } from '../ui/Toast'; +import { useRbac } from '../../auth/rbac'; +import { + getActivities, + getMilestones, + getProjectIndicators, + getProjectRisks, + getBudgets, + updateProjectStatus, +} from '../../services/api'; +import { Project, ApprovalStatus, Programme } from '../../types'; +import { + formatDate, + formatZAR, + getApprovalStatusBadge, + getPriorityBadge, + getRiskScoreColor, + getUserEmail, + getIndustrySectorLabel, + getProjectTypeLabel, + APPROVAL_STATUS_OPTIONS, +} from '../../lib/formatters'; +import { ProjectTimelineGantt } from './ProjectTimelineGantt'; +import { evaluateProjectScheduleRisk } from '../../pages/ProjectsPage'; + +interface ProjectViewModalProps { + isOpen: boolean; + onClose: () => void; + project: Project | null; + programmeMap: Map; + onOpenMilestonesModal: () => void; + onOpenActivitiesModal: () => void; + onOpenIndicatorsModal: () => void; + onOpenRisksModal: () => void; + onOpenEditProject: () => void; +} + +export const ProjectViewModal: React.FC = ({ + isOpen, + onClose, + project, + programmeMap, + onOpenMilestonesModal, + onOpenActivitiesModal, + onOpenIndicatorsModal, + onOpenRisksModal, + onOpenEditProject, +}) => { + const queryClient = useQueryClient(); + const { success, error: toastError } = useToast(); + const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac(); + + const projectId = project?.id; + + // Queries for project resources + const { + data: activities = [], + isLoading: loadingActivities, + refetch: refetchActivities, + } = useQuery({ + queryKey: ['activities', projectId], + queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])), + enabled: isOpen && !!projectId, + }); + + const { + data: milestones = [], + isLoading: loadingMilestones, + refetch: refetchMilestones, + } = useQuery({ + queryKey: ['milestones', projectId], + queryFn: () => (projectId ? getMilestones(projectId) : Promise.resolve([])), + enabled: isOpen && !!projectId, + }); + + const { + data: indicators = [], + isLoading: loadingIndicators, + refetch: refetchIndicators, + } = useQuery({ + queryKey: ['indicators', projectId], + queryFn: () => (projectId ? getProjectIndicators(projectId) : Promise.resolve([])), + enabled: isOpen && !!projectId, + }); + + const { + data: risks = [], + isLoading: loadingRisks, + refetch: refetchRisks, + } = useQuery({ + queryKey: ['risks', projectId], + queryFn: () => (projectId ? getProjectRisks(projectId) : Promise.resolve([])), + enabled: isOpen && !!projectId, + }); + + const { data: allBudgets = [] } = useQuery({ + queryKey: ['budgets'], + queryFn: () => getBudgets(1, 100), + enabled: isOpen && !!projectId, + }); + + // Governance Status Mutation + const statusMutation = useMutation({ + mutationFn: ({ projectId, status }: { projectId: number | string; status: ApprovalStatus }) => + updateProjectStatus({ projectId, status }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['projects'] }); + success('Status Updated', 'Project governance status updated successfully'); + }, + onError: (err: unknown) => { + toastError('Failed to update status', err); + }, + }); + + // Calculate budgets + const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId)); + const approvedBudgets = projectBudgets.filter( + (b) => + b.status === undefined || + b.status === null || + Number(b.status) === ApprovalStatus.Approved || + Number(b.status) === 3 + ); + const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0); + + // Milestone Progress + const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length; + const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0; + const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0); + + // Schedule Risk + const scheduleRisk = useMemo(() => { + if (!project) return null; + return evaluateProjectScheduleRisk(project, { total: milestones.length, verified: verifiedMilestonesCount }); + }, [project, milestones.length, verifiedMilestonesCount]); + + // Total Duration + const durationDays = useMemo(() => { + if (!project?.startedAt || !project?.endedAt) return 0; + return Math.max( + 1, + Math.round( + (new Date(project.endedAt).getTime() - new Date(project.startedAt).getTime()) / + (1000 * 60 * 60 * 24) + ) + ); + }, [project?.startedAt, project?.endedAt]); + + const prog = project ? programmeMap.get(project.programmeId) : null; + + if (!project) return null; + + const canApproveThis = canApproveRecord('projects_milestones', project.createdBy); + const isSelf = isMaker(project.createdBy); + const canModifyStatus = canAdd('projects_milestones') || canApprove('projects_milestones'); + + const handleRefreshAll = () => { + refetchActivities(); + refetchMilestones(); + refetchIndicators(); + refetchRisks(); + }; + + return ( + +
+ {/* Top Header Banner: Project Title, ID, Sector, Status, and Primary Actions */} +
+
+ +
+ {/* Project Meta Info */} +
+
+ + Project #{project.id} + + + {getIndustrySectorLabel(project.sector)} + + + {getProjectTypeLabel(project.projectType)} + + • + + Programme:{' '} + + {prog?.name || `Prog #${project.programmeId}`} + + +
+ +

+ {project.name || `Project #${project.id}`} +

+ +
+ + Owner:{' '} + {getUserEmail(project.createdBy)} + + • +
+ + + {formatDate(project.startedAt)} → {formatDate(project.endedAt)} ({durationDays} Days) + +
+ {approvedBudgetTotal > 0 && ( + <> + • + + Budget: {formatZAR(approvedBudgetTotal)} + + + )} +
+
+ + {/* Status & Action Controls */} +
+ {/* Schedule Risk Pill */} + {scheduleRisk && ( +
+ + {scheduleRisk.isOverdue ? ( + + ) : scheduleRisk.isAtRisk ? ( + + ) : ( + + )} + + {scheduleRisk.isOverdue + ? 'Overdue Schedule' + : scheduleRisk.isAtRisk + ? `At Risk (${scheduleRisk.daysRemaining}d)` + : 'On Track'} + + + +
+ )} + + {/* Real-time Governance Status Dropdown */} +
+ +
+ + {/* Action Buttons */} +
+ + + Evidence Queue + + + + + +
+
+
+ + {/* Project Narrative */} + {project.description && ( +
+ Scope Narrative: + {project.description} +
+ )} +
+ + {/* 5 Vital Project KPI Metrics Bar */} +
+ {/* Project Duration */} +
+
+ Project Duration + +
+ + {durationDays} Days + + + {formatDate(project.startedAt)} → {formatDate(project.endedAt)} + +
+ + {/* Milestone Progress */} +
+
+ Milestone Progress + +
+
+ {milestoneProgress}% + + ({verifiedMilestonesCount}/{milestones.length} Verified) + +
+
+
+
+
+ + {/* Work Breakdown Cost */} +
+
+ Work Breakdown Cost + +
+ + {formatZAR(totalActivityBudget)} + + + {activities.length} total recorded tasks + +
+ + {/* Governance State */} +
+
+ Governance State + +
+
+ {(() => { + const b = getApprovalStatusBadge(project.status); + return ( + + {b.label} + + ); + })()} +
+ Statutory Clearance Gate +
+ + {/* Approved Budget Total */} +
+
+ Approved Budget Value + +
+ + {formatZAR(approvedBudgetTotal)} + + + {approvedBudgets.length > 0 + ? `${approvedBudgets.length} approved budget line${approvedBudgets.length === 1 ? '' : 's'}` + : 'Allocated Project Budget'} + +
+
+ + {/* Enhanced Navigable Timeline Gantt Chart Component (Requirements 4, 5, 6) */} + + + {/* 4 Vital Resource Summary Cards (Milestones, Activities, Indicators, Risks) */} +
+ {/* Card 1: Milestones */} +
+
+
+
+ + Contractual Milestones ({milestones.length}) +
+ + {verifiedMilestonesCount}/{milestones.length} Verified + +
+ + {milestones.length === 0 ? ( +

No contractual milestones defined.

+ ) : ( +
+ {milestones.slice(0, 4).map((m) => { + const sBadge = getApprovalStatusBadge(m.status); + return ( +
+
+ {m.name} + + {sBadge.label} + +
+
+ + + Due: {formatDate(m.dueAt)} + + #{m.id} +
+
+ ); + })} + {milestones.length > 4 && ( +

+ +{milestones.length - 4} more deliverables +

+ )} +
+ )} +
+ + +
+ + {/* Card 2: Activities */} +
+
+
+
+ + Work Activities ({activities.length}) +
+ + {formatZAR(totalActivityBudget)} + +
+ + {activities.length === 0 ? ( +

No work activities recorded.

+ ) : ( +
+ {activities.slice(0, 4).map((act) => ( +
+
+ {act.title} + {formatZAR(act.amount)} +
+
+ {act.category || 'Operations'} + {formatDate(act.performedAt)} +
+
+ ))} + {activities.length > 4 && ( +

+ +{activities.length - 4} more tasks +

+ )} +
+ )} +
+ + +
+ + {/* Card 3: Indicators */} +
+
+
+
+ + M&E Indicators ({indicators.length}) +
+ + Results Chain + +
+ + {indicators.length === 0 ? ( +

No M&E indicators defined.

+ ) : ( +
+ {indicators.slice(0, 4).map((ind) => { + const target = Number(ind.targetAmount) || 1; + const actual = Number(ind.actualAmount) || 0; + const pct = Math.round((actual / target) * 100); + return ( +
+
+ {ind.name} + = 100 ? 'text-emerald-400 font-bold' : 'text-indigo-400 font-bold' + } + > + {pct}% + +
+
+ + Actual: {actual} + + + Target: {target} + +
+
+ ); + })} + {indicators.length > 4 && ( +

+ +{indicators.length - 4} more indicators +

+ )} +
+ )} +
+ + +
+ + {/* Card 4: Risks */} +
+
+
+
+ + Risk Register ({risks.length}) +
+ + ISO 31000 + +
+ + {risks.length === 0 ? ( +

No project hazards logged.

+ ) : ( +
+ {risks.slice(0, 4).map((rk) => { + const score = (rk.likelihood || 1) * (rk.impact || 1); + const riskInfo = getRiskScoreColor(rk.likelihood || 1, rk.impact || 1); + return ( +
+
+ {rk.name} + + {score}/25 {riskInfo.level} + +
+

{rk.description}

+
+ ); + })} + {risks.length > 4 && ( +

+ +{risks.length - 4} more risks +

+ )} +
+ )} +
+ + +
+
+
+ + ); +}; + diff --git a/PostFundManagement.ConsoleUI/src/components/ui/Modal.tsx b/PostFundManagement.ConsoleUI/src/components/ui/Modal.tsx index 343d4a9..03e079c 100644 --- a/PostFundManagement.ConsoleUI/src/components/ui/Modal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/ui/Modal.tsx @@ -10,8 +10,9 @@ export interface ModalProps { subtitle?: string; helpText?: ReactNode; children: ReactNode; - maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full'; - size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full' | string; + maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl' | 'full-screen' | 'full'; + size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl' | 'full-screen' | 'full' | string; + zIndex?: string; } export const Modal: React.FC = ({ @@ -23,6 +24,7 @@ export const Modal: React.FC = ({ children, maxWidth = 'lg', size, + zIndex = 'z-[9999]', }) => { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -57,13 +59,15 @@ export const Modal: React.FC = ({ '4xl': 'max-w-4xl', '5xl': 'max-w-5xl', '6xl': 'max-w-6xl', + '7xl': 'max-w-7xl', + 'full-screen': 'max-w-[96vw]', full: 'max-w-full', }; const maxWidthClass = maxWidthClassMap[effectiveSize] || 'max-w-lg'; const modalContent = (
setIsPaused(true)} onMouseLeave={() => setIsPaused(false)} - className={`pointer-events-auto relative flex flex-col rounded-xl border shadow-2xl transition-all duration-200 overflow-hidden bg-[#111622] ${ + className={`pointer-events-auto relative flex flex-col rounded-2xl border shadow-2xl transition-all duration-200 overflow-hidden bg-[#0d1322] backdrop-blur-none ${ toast.type === 'success' - ? 'border-emerald-500/30 text-gray-200' + ? 'border-emerald-500/60 text-gray-100 shadow-emerald-950/40 ring-1 ring-emerald-500/20' : toast.type === 'error' - ? 'border-rose-500/40 text-gray-200 shadow-rose-950/20' - : 'border-blue-500/30 text-gray-200' + ? 'border-rose-500/70 text-gray-100 shadow-rose-950/60 ring-1 ring-rose-500/30' + : 'border-blue-500/60 text-gray-100 shadow-blue-950/40 ring-1 ring-blue-500/20' }`} >
@@ -360,18 +361,23 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) = }); }; + const toastRegion = ( +
+ {toasts.map((t) => ( + + ))} +
+ ); + return ( {children} -
- {toasts.map((t) => ( - - ))} -
+ {typeof document !== 'undefined' ? createPortal(toastRegion, document.body) : null}
); }; diff --git a/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx index 66e4d8b..fffbe61 100644 --- a/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx @@ -21,6 +21,7 @@ import { AlertCircle, CheckCircle2, SlidersHorizontal, + Eye, } from 'lucide-react'; import { getProjects, @@ -57,7 +58,7 @@ import { APPROVAL_STATUS_OPTIONS, getUserEmail, } from '../lib/formatters'; -import { ProjectExpandedRow } from '../components/projects/ProjectExpandedRow'; +import { ProjectViewModal } from '../components/projects/ProjectViewModal'; import { ProjectFormModal } from '../components/projects/ProjectFormModal'; import { ProjectMilestonesModal } from '../components/projects/ProjectMilestonesModal'; import { ProjectActivitiesModal } from '../components/projects/ProjectActivitiesModal'; @@ -158,8 +159,9 @@ export const ProjectsPage: React.FC = () => { const [selectedSectorFilter, setSelectedSectorFilter] = useState('all'); const [selectedStatusFilter, setSelectedStatusFilter] = useState('all'); - // Expanded Rows State (store project IDs) - const [expandedRowIds, setExpandedRowIds] = useState>(new Set()); + // Active Project View Modal State + const [viewingProject, setViewingProject] = useState(null); + const [isProjectViewModalOpen, setIsProjectViewModalOpen] = useState(false); // Modals Management State const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); @@ -369,26 +371,11 @@ export const ProjectsPage: React.FC = () => { customExtractors ); - // Toggle Single Row Expansion - const toggleRow = (id: string | number) => { - setExpandedRowIds((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }; - - // Expand / Collapse All Rows - const toggleAllRows = () => { - if (expandedRowIds.size === sortedProjects.length && sortedProjects.length > 0) { - setExpandedRowIds(new Set()); - } else { - setExpandedRowIds(new Set(sortedProjects.map((p) => p.id))); - } + // Handler to open comprehensive Project View Modal + const handleOpenProjectView = (p: Project, e?: React.MouseEvent) => { + if (e) e.stopPropagation(); + setViewingProject(p); + setIsProjectViewModalOpen(true); }; // Handlers to open resource modals @@ -447,7 +434,7 @@ export const ProjectsPage: React.FC = () => { }, [projects, milestonesByProject]); return ( -
+
{/* Header Banner */}
@@ -703,43 +690,30 @@ export const ProjectsPage: React.FC = () => {

Funded Projects Directory ({sortedProjects.length})

- - • Click any row to expand the Gantt timeline & resource details + + • Click "View Project" or project row to open the Project View modal & Gantt timeline
- -
- -
{/* Projects Table */} -
- +
+
- - + Project Name & ID - + Parent Programme - + Schedule (Timeline) - + WBS Progress - +
Schedule Risk { />
- + Status -
+ {loadingProjects ? ( - ) : sortedProjects.length === 0 ? ( - ) : ( sortedProjects.map((p) => { - const isExpanded = expandedRowIds.has(p.id); const prog = programmeMap.get(p.programmeId); const milestoneStats = milestonesByProject.get(p.id) || { total: 0, verified: 0 }; const progressPct = @@ -799,58 +772,50 @@ export const ProjectsPage: React.FC = () => { const scheduleRisk = evaluateProjectScheduleRisk(p, milestoneStats); return ( - - {/* Main Project Row */} - toggleRow(p.id)} - className={`transition-colors cursor-pointer group ${ - isExpanded - ? 'bg-[#0f172a]/90 text-white' - : 'hover:bg-slate-900/60 text-slate-300' - }`} - > - {/* Expand/Collapse Chevron */} - - - {/* Project Name & Meta */} - handleOpenProjectView(p)} + className="transition-colors cursor-pointer hover:bg-slate-900/60 text-slate-300 group" + > + {/* Project Name & Meta */} + +
+ + #{p.id} + + + {getIndustrySectorLabel(p.sector)} + + • + + {getProjectTypeLabel(p.projectType)} + +
+ + {/* Parent Programme */} - {/* Schedule (Dates) */} - {/* Milestone Progress */} - {/* Schedule Risk Column with Icon & Tooltip */} - {/* Governance Status */} - - {/* Row Level Buttons: Milestones, Activities, Indicators, Risks, Edit */} - - - {/* Expanded View Content */} - {isExpanded && ( - - - - )} - - ); + ); }) )} @@ -1076,6 +1035,47 @@ export const ProjectsPage: React.FC = () => { + {/* Project View Modal (Comprehensive Console, Navigable Gantt Timeline & WBS) */} + { + setIsProjectViewModalOpen(false); + setViewingProject(null); + }} + project={viewingProject} + programmeMap={programmeMap} + onOpenMilestonesModal={() => { + if (viewingProject) { + setActiveModalProject(viewingProject); + setIsMilestonesModalOpen(true); + } + }} + onOpenActivitiesModal={() => { + if (viewingProject) { + setActiveModalProject(viewingProject); + setIsActivitiesModalOpen(true); + } + }} + onOpenIndicatorsModal={() => { + if (viewingProject) { + setActiveModalProject(viewingProject); + setIsIndicatorsModalOpen(true); + } + }} + onOpenRisksModal={() => { + if (viewingProject) { + setActiveModalProject(viewingProject); + setIsRisksModalOpen(true); + } + }} + onOpenEditProject={() => { + if (viewingProject) { + setEditingProject(viewingProject); + setIsProjectModalOpen(true); + } + }} + /> + {/* Project Form Modal (Create or Edit) */}
ActionsActions
+
Loading project directory... @@ -768,7 +742,7 @@ export const ProjectsPage: React.FC = () => {
+

No matching projects found

@@ -789,7 +763,6 @@ export const ProjectsPage: React.FC = () => {
- - -
-
- - {p.name} - -
-
- - #{p.id} - - - {getIndustrySectorLabel(p.sector)} - - • - - {getProjectTypeLabel(p.projectType)} - -
+
+
+
+ + {p.name || `Project #${p.id}`} +
-
+
- + {prog?.name || `Prog #${p.programmeId}`} @@ -860,8 +825,8 @@ export const ProjectsPage: React.FC = () => {
-
+
+
{formatDate(p.startedAt)} → {formatDate(p.endedAt)} @@ -882,8 +847,8 @@ export const ProjectsPage: React.FC = () => {
-
+
+
= 100 ? 'text-emerald-400' : 'text-blue-400'}> {progressPct}% @@ -906,7 +871,7 @@ export const ProjectsPage: React.FC = () => {
e.stopPropagation()}> + e.stopPropagation()}>
{scheduleRisk.isOverdue ? ( {
e.stopPropagation()}> -
+
e.stopPropagation()}> +
{(() => { const canApproveThis = canApproveRecord('projects_milestones', p.createdBy); const isSelf = isMaker(p.createdBy); @@ -969,7 +934,7 @@ export const ProjectsPage: React.FC = () => { status: Number(e.target.value) as ApprovalStatus, }) } - className="w-full text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed truncate" + className="w-full text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2 py-1 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed truncate" > {APPROVAL_STATUS_OPTIONS.map((opt) => { const isApprovedOpt = opt.value === ApprovalStatus.Approved; @@ -992,51 +957,62 @@ export const ProjectsPage: React.FC = () => {
e.stopPropagation()}> + {/* Row Level Buttons: View Project, Milestones, Activities, Indicators, Risks, Edit */} + e.stopPropagation()}>
+ {/* Primary View Project Modal Button */} + + {/* Milestones Modal Button */} {/* Activities Modal Button */} {/* Indicators Modal Button */} {/* Risks Modal Button */} {/* Edit Project Button */} @@ -1051,24 +1027,7 @@ export const ProjectsPage: React.FC = () => {
- handleOpenMilestones(p)} - onOpenActivitiesModal={() => handleOpenActivities(p)} - onOpenIndicatorsModal={() => handleOpenIndicators(p)} - onOpenRisksModal={() => handleOpenRisks(p)} - onOpenEditProject={() => handleOpenEditProject(p)} - /> -