import React, { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { FolderKanban, Plus, Search, RotateCcw, ChevronDown, ChevronRight, Flag, Briefcase, Target, ShieldAlert, Edit2, Calendar, Clock, TrendingUp, FileCheck, AlertTriangle, AlertCircle, CheckCircle2, SlidersHorizontal, } from 'lucide-react'; import { getProjects, getAllProgrammes, getProgrammeProjects, getAllMilestones, getAllRisks, getAllIndicators, getBudgets, 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 { useRbac } from '../auth/rbac'; import { useTableSort, SortableTh } from '../utils/tableSort'; import { Project, Programme, Milestone, ApprovalStatus, IndustrySector, ProjectType, } from '../types'; import { getIndustrySectorLabel, getProjectTypeLabel, getApprovalStatusBadge, formatDate, formatZAR, INDUSTRY_SECTOR_OPTIONS, PROJECT_TYPE_OPTIONS, APPROVAL_STATUS_OPTIONS, getUserEmail, } from '../lib/formatters'; import { ProjectExpandedRow } from '../components/projects/ProjectExpandedRow'; import { ProjectFormModal } from '../components/projects/ProjectFormModal'; import { ProjectMilestonesModal } from '../components/projects/ProjectMilestonesModal'; import { ProjectActivitiesModal } from '../components/projects/ProjectActivitiesModal'; 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(); const { canAdd, canExport } = useRbac(); // Filter & Search States const [searchTerm, setSearchTerm] = useState(''); const [selectedProgrammeFilter, setSelectedProgrammeFilter] = useState('all'); const [selectedSectorFilter, setSelectedSectorFilter] = useState('all'); const [selectedStatusFilter, setSelectedStatusFilter] = useState('all'); // Expanded Rows State (store project IDs) const [expandedRowIds, setExpandedRowIds] = useState>(new Set()); // Modals Management State const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); const [editingProject, setEditingProject] = useState(null); const [activeModalProject, setActiveModalProject] = useState(null); const [isMilestonesModalOpen, setIsMilestonesModalOpen] = useState(false); const [isActivitiesModalOpen, setIsActivitiesModalOpen] = useState(false); const [isIndicatorsModalOpen, setIsIndicatorsModalOpen] = useState(false); const [isRisksModalOpen, setIsRisksModalOpen] = useState(false); // Queries const { data: programmes = [] } = useQuery({ queryKey: ['programmes'], queryFn: () => getAllProgrammes(), }); const { data: projects = [], isLoading: loadingProjects, refetch: refetchProjects, isFetching: isFetchingProjects, } = useQuery({ queryKey: ['projects', selectedProgrammeFilter], queryFn: () => selectedProgrammeFilter !== 'all' ? getProgrammeProjects(selectedProgrammeFilter) : getProjects(), }); const { data: allMilestones = [] } = useQuery({ queryKey: ['milestones-all'], queryFn: () => getAllMilestones(), }); const { data: allRisks = [] } = useQuery({ queryKey: ['risks-all'], queryFn: () => getAllRisks(), }); const { data: allIndicators = [] } = useQuery({ queryKey: ['indicators-all'], queryFn: () => getAllIndicators(), }); const { data: allBudgets = [] } = useQuery({ queryKey: ['budgets'], queryFn: () => getBudgets(1, 100), }); // Programme lookup map const programmeMap = useMemo(() => { const map = new Map(); programmes.forEach((p) => map.set(p.id, p)); return map; }, [programmes]); // Project Milestones Count Map const milestonesByProject = useMemo(() => { const map = new Map(); allMilestones.forEach((m) => { const pId = m.projectId; if (pId !== undefined && pId !== null) { const current = map.get(pId) || { total: 0, verified: 0 }; current.total += 1; if (m.status === ApprovalStatus.Approved) { current.verified += 1; } map.set(pId, current); } }); return map; }, [allMilestones]); // Project Approved Budget Map const approvedBudgetsByProject = useMemo(() => { const map = new Map(); allBudgets.forEach((b) => { const pId = b.projectId; if (pId !== undefined && pId !== null) { if ( b.status === undefined || b.status === null || Number(b.status) === ApprovalStatus.Approved || Number(b.status) === 3 ) { const current = map.get(pId) || 0; map.set(pId, current + (Number(b.amount) || 0)); } } }); return map; }, [allBudgets]); // 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 changed successfully'); }, onError: (err: unknown) => { toastError('Failed to update status', err); }, }); // Filtered Projects const filteredProjects = useMemo(() => { return projects.filter((p) => { // Search term if (searchTerm.trim()) { const query = searchTerm.toLowerCase(); const matchesName = (p.name || '').toLowerCase().includes(query); const matchesDesc = (p.description || '').toLowerCase().includes(query); const matchesId = String(p.id).toLowerCase().includes(query); const matchesSector = getIndustrySectorLabel(p.sector).toLowerCase().includes(query); if (!matchesName && !matchesDesc && !matchesId && !matchesSector) { return false; } } // Sector filter if (selectedSectorFilter !== 'all' && Number(p.sector) !== Number(selectedSectorFilter)) { return false; } // Status filter if (selectedStatusFilter !== 'all' && Number(p.status) !== Number(selectedStatusFilter)) { return false; } return true; }); }, [projects, searchTerm, selectedSectorFilter, selectedStatusFilter]); // Export Columns Configuration const projectExportColumns: ExportColumn[] = useMemo(() => [ { header: 'Project ID', key: 'id', format: (val) => `#${val}` }, { header: 'Project Name', key: 'name', format: (val) => val || 'Untitled' }, { header: 'Parent Programme', key: 'programmeId', format: (val) => programmeMap.get(val)?.name || `Prog #${val}`, }, { header: 'Industry Sector', key: 'sector', format: (val) => getIndustrySectorLabel(val) }, { header: 'Project Type', key: 'projectType', format: (val) => getProjectTypeLabel(val) }, { header: 'Start Date', key: 'startedAt', format: (val) => formatDate(val) }, { header: 'Target End Date', key: 'endedAt', format: (val) => formatDate(val) }, { header: 'Schedule Risk Alert', key: 'id', format: (_, 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', key: 'id', format: (_, 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: 'Approved Budget (ZAR)', key: 'id', format: (_, p) => { const amt = approvedBudgetsByProject.get(p.id) || 0; return amt > 0 ? formatZAR(amt) : 'R 0'; }, }, { header: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label }, { header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) }, ], [programmeMap, milestonesByProject, approvedBudgetsByProject]); // Table Sorting const customExtractors = { name: (p: Project) => (p.name || '').toLowerCase(), programme: (p: Project) => (programmeMap.get(p.programmeId)?.name || '').toLowerCase(), status: (p: Project) => Number(p.status) || 0, progress: (p: Project) => { const stats = milestonesByProject.get(p.id); return stats && stats.total > 0 ? (stats.verified / stats.total) * 100 : 0; }, duration: (p: Project) => { 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, 'risk', 'asc', 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))); } }; // Handlers to open resource modals const handleOpenMilestones = (p: Project, e?: React.MouseEvent) => { if (e) e.stopPropagation(); setActiveModalProject(p); setIsMilestonesModalOpen(true); }; const handleOpenActivities = (p: Project, e?: React.MouseEvent) => { if (e) e.stopPropagation(); setActiveModalProject(p); setIsActivitiesModalOpen(true); }; const handleOpenIndicators = (p: Project, e?: React.MouseEvent) => { if (e) e.stopPropagation(); setActiveModalProject(p); setIsIndicatorsModalOpen(true); }; const handleOpenRisks = (p: Project, e?: React.MouseEvent) => { if (e) e.stopPropagation(); setActiveModalProject(p); setIsRisksModalOpen(true); }; const handleOpenEditProject = (p: Project, e?: React.MouseEvent) => { if (e) e.stopPropagation(); setEditingProject(p); setIsProjectModalOpen(true); }; const handleOpenCreateProject = () => { setEditingProject(null); setIsProjectModalOpen(true); }; const resetFilters = () => { setSearchTerm(''); setSelectedProgrammeFilter('all'); setSelectedSectorFilter('all'); setSelectedStatusFilter('all'); }; // Key KPI Aggregations const totalApprovedProjects = projects.filter((p) => p.status === ApprovalStatus.Approved).length; const verifiedMilestonesTotal = allMilestones.filter((m) => m.status === ApprovalStatus.Approved).length; const milestoneCompletionRate = allMilestones.length > 0 ? Math.round((verifiedMilestonesTotal / allMilestones.length) * 100) : 0; const atRiskProjectsCount = useMemo(() => { return projects.filter((p) => { const stats = milestonesByProject.get(p.id); return evaluateProjectScheduleRisk(p, stats).isAtRisk; }).length; }, [projects, milestonesByProject]); return (
{/* Header Banner */}

Project & Milestone Management

Interactive WBS Console

Manage operational activities, verified contractual milestone deliverables, risk matrices, and M&E performance indicator targets.

{canAdd('projects_milestones') && ( )}
{/* KPI Overview Summary Cards */}
Total Projects
{projects.length} ({totalApprovedProjects} Approved)

Under {programmes.length} parent programmes

Milestone Progress
{milestoneCompletionRate}% ({verifiedMilestonesTotal}/{allMilestones.length})
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
{allIndicators.length} Tracked KPIs

Results & impact metrics

{/* Filter and Search Controls Bar */}
{/* Search Input */}
setSearchTerm(e.target.value)} placeholder="Search projects by name, ID, sector, or keywords..." className="w-full pl-9 pr-3 py-2 bg-slate-900/90 border border-slate-700/80 rounded-xl text-xs text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40" />
{/* Quick Filters */}
{/* Programme Filter */}
Programme:
{/* Sector Filter */}
Sector:
{/* Status Filter */}
Status:
{/* Clear Filter Button */} {(searchTerm || selectedProgrammeFilter !== 'all' || selectedSectorFilter !== 'all' || selectedStatusFilter !== 'all') && ( )} {/* Export Button */} {/* Single Refresh Button */}
{/* Active Filter Tags */} {(searchTerm || selectedProgrammeFilter !== 'all' || selectedSectorFilter !== 'all' || selectedStatusFilter !== 'all') && (
Active Filters: {searchTerm && ( Search: "{searchTerm}" )} {selectedProgrammeFilter !== 'all' && ( Prog: {programmeMap.get(selectedProgrammeFilter)?.name || `#${selectedProgrammeFilter}`} )} {selectedSectorFilter !== 'all' && ( Sector: {getIndustrySectorLabel(Number(selectedSectorFilter))} )} {selectedStatusFilter !== 'all' && ( Status: {getApprovalStatusBadge(Number(selectedStatusFilter)).label} )}
)}
{/* Main Projects Table Container */}
{/* Table Header Bar */}

Funded Projects Directory ({sortedProjects.length})

• Click any row to expand the Gantt timeline & resource details
{/* 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 = milestoneStats.total > 0 ? Math.round((milestoneStats.verified / milestoneStats.total) * 100) : 0; 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 */} {/* 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 && ( )} ); }) )}
Resource Management Hub
Loading project directory...

No matching projects found

{searchTerm || selectedProgrammeFilter !== 'all' || selectedSectorFilter !== 'all' ? 'Try adjusting your search query or filters.' : 'Register a project to begin building your work breakdown structure.'}

{p.name}
#{p.id} {getIndustrySectorLabel(p.sector)} • {getProjectTypeLabel(p.projectType)}
{prog?.name || `Prog #${p.programmeId}`} Programme #{p.programmeId}
{formatDate(p.startedAt)} → {formatDate(p.endedAt)}
{p.startedAt && p.endedAt && ( {Math.max( 1, Math.round( (new Date(p.endedAt).getTime() - new Date(p.startedAt).getTime()) / (1000 * 60 * 60 * 24 * 30) ) )}{' '} Months Estimated Duration )}
= 100 ? 'text-emerald-400' : 'text-blue-400'}> {progressPct}% ({milestoneStats.verified}/{milestoneStats.total})
= 100 ? 'bg-emerald-500' : 'bg-gradient-to-r from-blue-500 to-indigo-500' }`} style={{ width: `${progressPct}%` }} />
e.stopPropagation()}>
{scheduleRisk.isOverdue ? ( Overdue ) : scheduleRisk.isAtRisk ? ( At Risk ({scheduleRisk.daysRemaining}d) ) : scheduleRisk.iconType === 'on_track' ? ( On Track ) : ( Scheduled )}
e.stopPropagation()}>
e.stopPropagation()}>
{/* Milestones Modal Button */} {/* Activities Modal Button */} {/* Indicators Modal Button */} {/* Risks Modal Button */} {/* Edit Project Button */}
handleOpenMilestones(p)} onOpenActivitiesModal={() => handleOpenActivities(p)} onOpenIndicatorsModal={() => handleOpenIndicators(p)} onOpenRisksModal={() => handleOpenRisks(p)} onOpenEditProject={() => handleOpenEditProject(p)} />
{/* Project Form Modal (Create or Edit) */} { setIsProjectModalOpen(false); setEditingProject(null); }} editingProject={editingProject} programmes={programmes} defaultProgrammeId={selectedProgrammeFilter !== 'all' ? selectedProgrammeFilter : undefined} /> {/* Milestones Management Modal */} { setIsMilestonesModalOpen(false); setActiveModalProject(null); }} project={activeModalProject} /> {/* Activities Management Modal */} { setIsActivitiesModalOpen(false); setActiveModalProject(null); }} project={activeModalProject} /> {/* Indicators Management Modal */} { setIsIndicatorsModalOpen(false); setActiveModalProject(null); }} project={activeModalProject} /> {/* Risks Management Modal */} { setIsRisksModalOpen(false); setActiveModalProject(null); }} project={activeModalProject} />
); };