import React, { useState, useRef, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { ShieldAlert, FileCheck, Upload, Plus, CheckCircle2, XCircle, AlertTriangle, FileText, Clock, TrendingUp, Download, Paperclip, Check, Filter, Link as LinkIcon, Edit2, ChevronRight, RefreshCw, Search, RotateCcw, Activity, } from 'lucide-react'; import { getAllIndicators, createIndicator, recordIndicatorActual, getAllRisks, createRisk, updateRisk, getProjects, getAllMilestones, } from '../services/api'; import { Modal } from '../components/ui/Modal'; import { HelpTooltip } from '../components/ui/HelpTooltip'; import { useToast } from '../components/ui/Toast'; import { ExportButton } from '../components/ui/ExportButton'; import { ExportColumn } from '../utils/exportUtils'; import { useTableSort, SortableTh } from '../utils/tableSort'; import { useRbac } from '../auth/rbac'; import { ContractsManagement } from '../components/monitoring/ContractsManagement'; import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue'; import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix'; import { Indicator, Risk, ApprovalStatus, UnitOfMeasure, EvidenceType, RiskLikelihood, RiskImpact, CreateIndicatorRequest, CreateRiskRequest, } from '../types'; import { formatDate, getApprovalStatusBadge, getEvidenceTypeLabel, getUnitOfMeasureLabel, getRiskScoreColor, UNIT_OF_MEASURE_OPTIONS, RISK_LIKELIHOOD_OPTIONS, RISK_IMPACT_OPTIONS, EVIDENCE_TYPE_OPTIONS, getUserEmail, } from '../lib/formatters'; const indicatorSchema = z.object({ projectId: z.coerce.number().min(1, 'Select project'), name: z.string().min(5, 'Indicator definition required'), unitOfMeasure: z.coerce.number().min(1), baselineAmount: z.coerce.number().min(0), targetAmount: z.coerce.number().min(1, 'Target must be > 0'), }); const riskSchema = z.object({ projectId: z.coerce.number().min(1, 'Select project'), name: z.string().min(5, 'Risk title required'), description: z.string().min(10, 'Risk description required'), likelihood: z.coerce.number().min(1).max(5), impact: z.coerce.number().min(1).max(5), }); type IndicatorFormValues = z.infer; type RiskFormValues = z.infer; export const WORKFLOW_STAGES = [ { id: 'planned', label: 'Planned', step: 1, description: 'Milestone scope agreed and deliverables planned' }, { id: 'in_progress', label: 'In Progress', step: 2, description: 'Activity underway by recipient organisation' }, { id: 'submitted', label: 'Submitted', step: 3, description: 'Evidence and deliverables submitted by recipient' }, { id: 'under_review', label: 'Under Review', step: 4, description: 'Programme Manager verifying evidence against criteria' }, { id: 'verified_paid', label: 'Verified & Paid', step: 5, description: 'Milestone verified and disbursement authorized' }, ] as const; export const MonitoringPage: React.FC = () => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); const { canAdd, canExport } = useRbac(); const [searchParams, setSearchParams] = useSearchParams(); const tabParam = searchParams.get('tab'); const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'risks'>( tabParam === 'evidence' || tabParam === 'risks' ? tabParam : 'contracts' ); useEffect(() => { const t = searchParams.get('tab'); if (t === 'evidence' || t === 'risks' || t === 'contracts') { setActiveTab(t); } }, [searchParams]); const handleTabChange = (tab: 'contracts' | 'evidence' | 'risks') => { setActiveTab(tab); setSearchParams((prev) => { const next = new URLSearchParams(prev); next.set('tab', tab); return next; }); }; // Modals & States const [isIndicatorModalOpen, setIsIndicatorModalOpen] = useState(false); const [isRiskModalOpen, setIsRiskModalOpen] = useState(false); const [editingRisk, setEditingRisk] = useState(null); const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState(null); const [actualInput, setActualInput] = useState(0); // Indicator Filter States const [indicatorSearchTerm, setIndicatorSearchTerm] = useState(''); const [indicatorProjectFilter, setIndicatorProjectFilter] = useState('all'); const [indicatorUnitFilter, setIndicatorUnitFilter] = useState('all'); const [indicatorStatusFilter, setIndicatorStatusFilter] = useState('all'); // Risk Filter States const [riskSearchTerm, setRiskSearchTerm] = useState(''); const [riskProjectFilter, setRiskProjectFilter] = useState('all'); const [riskSeverityFilter, setRiskSeverityFilter] = useState('all'); // Queries const { data: indicators = [], isLoading: loadingIndicators, refetch: refetchIndicators, isFetching: isFetchingIndicators, } = useQuery({ queryKey: ['indicators'], queryFn: () => getAllIndicators(), }); const { data: risks = [], isLoading: loadingRisks, refetch: refetchRisks, isFetching: isFetchingRisks, } = useQuery({ queryKey: ['risks'], queryFn: () => getAllRisks(), }); const { data: projects = [] } = useQuery({ queryKey: ['projects'], queryFn: () => getProjects(), }); const { data: milestones = [] } = useQuery({ queryKey: ['milestones-all'], queryFn: () => getAllMilestones(), }); // Forms const indicatorForm = useForm({ resolver: zodResolver(indicatorSchema), defaultValues: { projectId: projects[0]?.id ? Number(projects[0].id) : 401, name: '', unitOfMeasure: UnitOfMeasure.Count, baselineAmount: 0, targetAmount: 100, }, }); const riskForm = useForm({ resolver: zodResolver(riskSchema), defaultValues: { projectId: projects[0]?.id ? Number(projects[0].id) : 401, name: '', description: '', likelihood: RiskLikelihood.Medium, impact: RiskImpact.Moderate, }, }); // Mutations const indicatorMutation = useMutation({ mutationFn: (data: CreateIndicatorRequest) => createIndicator(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['indicators'] }); success('Indicator defined', 'Indicator added to project monitoring plan (M08)'); setIsIndicatorModalOpen(false); indicatorForm.reset(); }, onError: (err: unknown) => { toastError('Failed to create indicator', err); }, }); const actualMutation = useMutation({ mutationFn: ({ id, amount }: { id: string | number; amount: number }) => recordIndicatorActual(id, amount), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['indicators'] }); success('Actual value recorded', 'M&E variance and indicator status recalculated (BR-009)'); setSelectedIndicatorForActual(null); }, onError: (err: unknown) => { toastError('Failed to record actual', err); }, }); const openCreateRisk = () => { setEditingRisk(null); riskForm.reset({ projectId: projects[0]?.id ? Number(projects[0].id) : 401, name: '', description: '', likelihood: RiskLikelihood.Medium, impact: RiskImpact.Moderate, }); setIsRiskModalOpen(true); }; const openEditRisk = (rk: Risk) => { setEditingRisk(rk); riskForm.reset({ projectId: Number(rk.projectId) || (projects[0]?.id ? Number(projects[0].id) : 401), name: rk.name || '', description: rk.description || '', likelihood: (rk.likelihood as RiskLikelihood) ?? RiskLikelihood.Medium, impact: (rk.impact as RiskImpact) ?? RiskImpact.Moderate, }); setIsRiskModalOpen(true); }; const riskMutation = useMutation({ mutationFn: async (data: RiskFormValues) => { if (editingRisk) { return updateRisk({ riskId: editingRisk.id, name: data.name, description: data.description, likelihood: Number(data.likelihood), impact: Number(data.impact), }); } return createRisk({ projectId: Number(data.projectId), name: data.name, description: data.description, likelihood: Number(data.likelihood), impact: Number(data.impact), }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['risks'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] }); success( editingRisk ? 'Risk updated' : 'Risk registered', editingRisk ? 'Risk profile updated in project register' : 'Risk item added to project register' ); setIsRiskModalOpen(false); setEditingRisk(null); riskForm.reset(); }, onError: (err: unknown) => { toastError(editingRisk ? 'Failed to update risk' : 'Failed to log risk', err); }, }); // Performance indicator thresholds: // Green >= 90%, Amber 70-89%, Red < 70% const getIndicatorStatus = (actual: number, target: number) => { if (!target) return { label: 'Pending', badge: 'bg-slate-800/60 text-slate-400 border-slate-700/60' }; const pct = (actual / target) * 100; if (pct >= 90) return { label: '≥90%', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' }; if (pct >= 70) return { label: '70–89%', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20' }; return { label: '<70%', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' }; }; const indicatorExportColumns: ExportColumn[] = [ { header: 'Indicator ID', key: 'id' }, { header: 'Indicator Statement', key: 'name' }, { header: 'Project ID', key: 'projectId', format: (val) => `Project #${val}` }, { header: 'Unit of Measure', key: 'unitOfMeasure', format: (val) => getUnitOfMeasureLabel(val) }, { header: 'Baseline Value', key: 'baselineAmount' }, { header: 'Target Value', key: 'targetAmount' }, { header: 'Actual Recorded', key: 'actualAmount' }, { header: 'Achievement %', key: 'actualAmount', format: (val, item) => (item.targetAmount ? `${Math.round((Number(val) / Number(item.targetAmount)) * 100)}%` : '—'), }, { header: 'Status', key: 'actualAmount', format: (val, item) => getIndicatorStatus(Number(val), Number(item.targetAmount)).label, }, ]; const filteredIndicators = indicators.filter((ind) => { if (indicatorProjectFilter !== 'all' && String(ind.projectId) !== String(indicatorProjectFilter)) { return false; } if (indicatorUnitFilter !== 'all' && String(ind.unitOfMeasure) !== String(indicatorUnitFilter)) { return false; } if (indicatorStatusFilter !== 'all') { const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount); if (indicatorStatusFilter === 'green' && statusObj.label !== '≥90%') return false; if (indicatorStatusFilter === 'amber' && statusObj.label !== '70–89%') return false; if (indicatorStatusFilter === 'red' && statusObj.label !== '<70%') return false; if (indicatorStatusFilter === 'pending' && statusObj.label !== 'Pending') return false; } if (!indicatorSearchTerm.trim()) return true; const q = indicatorSearchTerm.toLowerCase(); const name = (ind.name || '').toLowerCase(); const projId = String(ind.projectId || '').toLowerCase(); const unit = getUnitOfMeasureLabel(ind.unitOfMeasure).toLowerCase(); return name.includes(q) || projId.includes(q) || unit.includes(q); }); const riskExportColumns: ExportColumn[] = [ { header: 'Risk ID', key: 'id' }, { header: 'Risk Title', key: 'name' }, { header: 'Risk Description', key: 'description' }, { header: 'Project ID', key: 'projectId', format: (val) => `Project #${val}` }, { header: 'Likelihood (1–5)', key: 'likelihood' }, { header: 'Impact (1–5)', key: 'impact' }, { header: 'Residual Score', key: 'likelihood', format: (_, item) => `${item.likelihood * item.impact} / 25`, }, { header: 'Severity Level', key: 'likelihood', format: (_, item) => getRiskScoreColor(item.likelihood, item.impact).level, }, { header: 'Risk Owner', key: 'owner', format: (val) => getUserEmail(val) }, { header: 'Mitigation Action / Controls', key: 'mitigationAction', format: (val) => val || '—' }, ]; const filteredRisks = risks.filter((rk) => { if (riskProjectFilter !== 'all' && String(rk.projectId) !== String(riskProjectFilter)) { return false; } if (riskSeverityFilter !== 'all') { const color = getRiskScoreColor(rk.likelihood, rk.impact); if (riskSeverityFilter.toLowerCase() !== color.level.toLowerCase()) { return false; } } if (!riskSearchTerm.trim()) return true; const q = riskSearchTerm.toLowerCase(); const name = (rk.name || '').toLowerCase(); const desc = (rk.description || '').toLowerCase(); const projId = String(rk.projectId || '').toLowerCase(); const owner = getUserEmail(rk.owner).toLowerCase(); const mitig = (rk.mitigationAction || '').toLowerCase(); return name.includes(q) || desc.includes(q) || projId.includes(q) || owner.includes(q) || mitig.includes(q); }); // Table Sorting Hooks const { sortedData: sortedIndicators, sortColumn: indSortCol, sortDirection: indSortDir, handleSort: handleIndSort, } = useTableSort(filteredIndicators, null, null, { name: (ind) => ind.name, projectId: (ind) => ind.projectId, unitOfMeasure: (ind) => getUnitOfMeasureLabel(ind.unitOfMeasure), baselineAmount: (ind) => ind.baselineAmount, targetAmount: (ind) => ind.targetAmount, actualAmount: (ind) => ind.actualAmount, status: (ind) => getIndicatorStatus(ind.actualAmount, ind.targetAmount).label, }); const { sortedData: sortedRisks, sortColumn: riskSortCol, sortDirection: riskSortDir, handleSort: handleRiskSort, } = useTableSort(filteredRisks, null, null, { name: (rk) => rk.name, projectId: (rk) => rk.projectId, likelihood: (rk) => rk.likelihood, impact: (rk) => rk.impact, residualScore: (rk) => rk.likelihood * rk.impact, owner: (rk) => getUserEmail(rk.owner), mitigationAction: (rk) => rk.mitigationAction || '', }); return (
{/* Header Banner */}

M&E, Evidence & Risk Management

Verification workflow: Evidence Upload & Review Queue, Indicator Actuals Recording, and Risk Register.

{activeTab === 'risks' && canAdd('evidence_risks') && ( )}
{/* Tabs */}
{/* Tab 0: Contracts & Legal Documents with PDF Upload */} {activeTab === 'contracts' && ( )} {/* Tab 1: Project & Milestone Evidence Review Hub */} {activeTab === 'evidence' && ( )} {/* Tab 3: Risk Register & Heatmap (M09) */} {activeTab === 'risks' && (
{/* Live Heatmap Matrix with cell click inspection */} openEditRisk(r)} title="Project Risk Heatmap & Matrix" subtitle="ISO 31000 standard matrix — click on any cell to filter and inspect risks or edit mitigation controls" /> {/* Risk Search & Filter Toolbar */}
{/* Search */}
setRiskSearchTerm(e.target.value)} className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs" />
{/* Project Filter */}
{/* Severity Filter */}
{(riskSearchTerm || riskProjectFilter !== 'all' || riskSeverityFilter !== 'all') && ( )}
{/* Action Buttons: Refresh & Export */}
{/* Risk Register Table Card */}

Project Risk Register

Complete itemized audit log of identified risks and residual scores

Showing {filteredRisks.length} of {risks.length} risks
Risk Title & Description Project ID Likelihood (1–5) Impact (1–5) Residual Score Risk Owner Mitigation Control {loadingRisks ? ( ) : sortedRisks.length === 0 ? ( ) : ( sortedRisks.map((rk) => { const score = rk.likelihood * rk.impact; const color = getRiskScoreColor(rk.likelihood, rk.impact); return ( ); }) )}
Actions
Loading risk register from API...
{riskSearchTerm || riskProjectFilter !== 'all' || riskSeverityFilter !== 'all' ? 'No risks match your filter criteria.' : 'No risks recorded in the register. Click "Add Risk" to log your first risk item.'}
{rk.name} {rk.description} Project #{rk.projectId} {rk.likelihood} {rk.impact} {score} / 25 ({color.level}) {getUserEmail(rk.owner)} {rk.mitigationAction || 'Standard internal audit controls applied.'}
)} {/* Modal: Define Indicator */} setIsIndicatorModalOpen(false)} title="Define M&E Indicator" subtitle="Logical Framework & Theory of Change metric" helpText="Quantitative or qualitative KPI tracked across reporting periods against target thresholds to assess project efficacy." >
indicatorMutation.mutate(data as CreateIndicatorRequest))} className="space-y-4">
{/* Modal: Record Actual Amount */} setSelectedIndicatorForActual(null)} title="Record Indicator Actual Value" subtitle={selectedIndicatorForActual?.name || ''} helpText="Logs a certified field or audited measurement against the target, recalculating the achievement percentage and RAG rating." >
Baseline: {selectedIndicatorForActual?.baselineAmount} Target: {selectedIndicatorForActual?.targetAmount}
setActualInput(Number(e.target.value))} className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono transition" />
{/* Modal: Add or Edit Risk */} { setIsRiskModalOpen(false); setEditingRisk(null); }} title={editingRisk ? 'Edit Project Risk' : 'Add Risk to Risk Register'} subtitle={editingRisk ? `Updating risk item #${editingRisk.id}` : 'Identify and score project delivery risks'} helpText="Risks are scored by Likelihood (1-5) x Impact (1-5) to plot on the 5x5 Heatmap. High severity risks flag alerts for portfolio governance." >
riskMutation.mutate(data))} className="space-y-4"> {!editingRisk && (
)}