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, Lock, } 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 / Immutable Locked State */}
{Number(project.status) === ApprovalStatus.Approved ? (
Approved (Locked)
) : ( )}
{/* 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

)}
)}
); };