Files
pfm/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx
T
2026-10-22 11:25:55 +02:00

1055 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof indicatorSchema>;
type RiskFormValues = z.infer<typeof riskSchema>;
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<Risk | null>(null);
const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState<Indicator | null>(null);
const [actualInput, setActualInput] = useState<number>(0);
// Indicator Filter States
const [indicatorSearchTerm, setIndicatorSearchTerm] = useState('');
const [indicatorProjectFilter, setIndicatorProjectFilter] = useState<string | number | 'all'>('all');
const [indicatorUnitFilter, setIndicatorUnitFilter] = useState<string | number | 'all'>('all');
const [indicatorStatusFilter, setIndicatorStatusFilter] = useState<string>('all');
// Risk Filter States
const [riskSearchTerm, setRiskSearchTerm] = useState('');
const [riskProjectFilter, setRiskProjectFilter] = useState<string | number | 'all'>('all');
const [riskSeverityFilter, setRiskSeverityFilter] = useState<string>('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<IndicatorFormValues>({
resolver: zodResolver(indicatorSchema),
defaultValues: {
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
name: '',
unitOfMeasure: UnitOfMeasure.Count,
baselineAmount: 0,
targetAmount: 100,
},
});
const riskForm = useForm<RiskFormValues>({
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<Indicator>[] = [
{ 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<Risk>[] = [
{ 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 (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Header Banner */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-indigo-600/10 rounded-full blur-3xl pointer-events-none" />
<div className="space-y-2 relative z-10">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-2xl bg-indigo-600/20 border border-indigo-500/30 flex items-center justify-center text-indigo-400 shadow-lg shadow-indigo-500/10">
<Activity className="w-6 h-6" />
</div>
<div>
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
M&amp;E, Evidence &amp; Risk Management
</h1>
<HelpTooltip
title="Monitoring & Evaluation Hub"
content="Track performance indicators, verify tangible deliverable evidence against statutory milestones, and maintain proactive risk mitigation plans."
placement="right"
/>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1">
Verification workflow: Evidence Upload &amp; Review Queue, Indicator Actuals Recording, and Risk Register.
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
{activeTab === 'risks' && canAdd('evidence_risks') && (
<button
onClick={openCreateRisk}
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Add Risk to Register</span>
</button>
)}
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
<button
onClick={() => handleTabChange('contracts')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
activeTab === 'contracts'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<FileText className="w-4 h-4" />
<span>Contracts & PDF Documents</span>
<HelpTooltip
title="Executed Contracts & Vault"
content="Digitally executed bilateral agreements, legal riders, and authoritative PDF repository."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => handleTabChange('evidence')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
activeTab === 'evidence'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<FileCheck className="w-4 h-4" />
<span>Evidence Review & Verification</span>
<HelpTooltip
title="Evidence Review & Verification"
content="Peer review, document inspection, and verification sign-offs for deliverables submitted against projects and milestones."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => handleTabChange('risks')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
activeTab === 'risks'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<ShieldAlert className="w-4 h-4" />
<span>Risk Register & Heatmap ({risks.length})</span>
<HelpTooltip
title="Risk Register & Heatmap"
content="5x5 severity matrix scoring likelihood against impact to safeguard public grant investments."
size="sm"
placement="top"
/>
</button>
</div>
{/* Tab 0: Contracts & Legal Documents with PDF Upload */}
{activeTab === 'contracts' && (
<ContractsManagement />
)}
{/* Tab 1: Project & Milestone Evidence Review Hub */}
{activeTab === 'evidence' && (
<EvidenceReviewQueue
initialProjectId={searchParams.get('projectId') || 'all'}
initialMilestoneId={searchParams.get('milestoneId') || 'all'}
initialActivityId={searchParams.get('activityId') || 'all'}
initialUserId={searchParams.get('userId') || 'all'}
/>
)}
{/* Tab 3: Risk Register & Heatmap (M09) */}
{activeTab === 'risks' && (
<div className="space-y-6">
{/* Live Heatmap Matrix with cell click inspection */}
<RiskHeatmapMatrix
risks={risks}
projects={projects}
onSelectRisk={(r) => 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 */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 bg-[#0d121f]/90 p-4 rounded-2xl border border-slate-800/80 backdrop-blur-sm">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
placeholder="Search risks by title, description, owner, mitigations..."
value={riskSearchTerm}
onChange={(e) => 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"
/>
</div>
{/* Project Filter */}
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 whitespace-nowrap font-medium">Project:</label>
<select
value={riskProjectFilter}
onChange={(e) => setRiskProjectFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
>
<option value="all">All Projects ({projects.length})</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name} (#{p.id})
</option>
))}
</select>
</div>
{/* Severity Filter */}
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 whitespace-nowrap flex items-center gap-1 font-medium">
<Filter className="w-3.5 h-3.5 text-slate-400" />
Severity:
</label>
<select
value={riskSeverityFilter}
onChange={(e) => setRiskSeverityFilter(e.target.value)}
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
>
<option value="all">All Severities</option>
<option value="critical">Critical (16–25)</option>
<option value="high">High (12–15)</option>
<option value="medium">Medium (6–10)</option>
<option value="low">Low (1–5)</option>
</select>
</div>
{(riskSearchTerm || riskProjectFilter !== 'all' || riskSeverityFilter !== 'all') && (
<button
type="button"
onClick={() => {
setRiskSearchTerm('');
setRiskProjectFilter('all');
setRiskSeverityFilter('all');
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-white px-2.5 py-2 bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
>
<RotateCcw className="w-3 h-3" />
<span>Reset</span>
</button>
)}
</div>
{/* Action Buttons: Refresh & Export */}
<div className="flex items-center gap-2 self-end lg:self-center">
<button
type="button"
onClick={() => {
refetchRisks();
success('Refreshed', 'Latest risk evaluations and residual scores loaded.');
}}
disabled={isFetchingRisks}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Refresh risks from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingRisks ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
claim="evidence_risks"
title="Project Risk Register & Heatmap"
subtitle="Itemized audit log of identified risks and mitigation controls"
filename={`pfm-risks-${new Date().toISOString().split('T')[0]}`}
columns={riskExportColumns}
data={filteredRisks}
/>
</div>
</div>
{/* Risk Register Table Card */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<div>
<h4 className="text-sm font-bold text-white flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-blue-400" />
Project Risk Register
</h4>
<p className="text-[11px] text-slate-400">Complete itemized audit log of identified risks and residual scores</p>
</div>
<span className="text-xs text-slate-400 font-mono">
Showing {filteredRisks.length} of {risks.length} risks
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<SortableTh column="name" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} className="py-3.5 px-5">
Risk Title & Description
</SortableTh>
<SortableTh column="projectId" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} className="py-3.5 px-4">
Project ID
</SortableTh>
<SortableTh column="likelihood" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} align="center" className="py-3.5 px-4 text-center">
Likelihood (1–5)
</SortableTh>
<SortableTh column="impact" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} align="center" className="py-3.5 px-4 text-center">
Impact (1–5)
</SortableTh>
<SortableTh column="residualScore" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} align="center" className="py-3.5 px-4 text-center">
Residual Score
</SortableTh>
<SortableTh column="owner" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} className="py-3.5 px-4">
Risk Owner
</SortableTh>
<SortableTh column="mitigationAction" currentColumn={riskSortCol} direction={riskSortDir} onSort={handleRiskSort} className="py-3.5 px-5">
Mitigation Control
</SortableTh>
<th className="py-3.5 px-4 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingRisks ? (
<tr>
<td colSpan={8} className="py-12 text-center text-slate-500">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
Loading risk register from API...
</td>
</tr>
) : sortedRisks.length === 0 ? (
<tr>
<td colSpan={8} className="py-12 text-center text-slate-500">
<ShieldAlert className="w-8 h-8 text-slate-600 mx-auto mb-2" />
{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.'}
</td>
</tr>
) : (
sortedRisks.map((rk) => {
const score = rk.likelihood * rk.impact;
const color = getRiskScoreColor(rk.likelihood, rk.impact);
return (
<tr key={rk.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
<span className="font-semibold text-white block text-xs">{rk.name}</span>
<span className="text-[11px] text-slate-400 line-clamp-1">{rk.description}</span>
</td>
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{rk.projectId}</td>
<td className="py-3.5 px-4 text-center font-mono font-bold text-slate-200">{rk.likelihood}</td>
<td className="py-3.5 px-4 text-center font-mono font-bold text-slate-200">{rk.impact}</td>
<td className="py-3.5 px-4 text-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-bold ${color.badge}`}>
{score} / 25 ({color.level})
</span>
</td>
<td className="py-3.5 px-4 text-slate-300 font-medium">{getUserEmail(rk.owner)}</td>
<td className="py-3.5 px-5 text-slate-400 text-[11px]">
{rk.mitigationAction || 'Standard internal audit controls applied.'}
</td>
<td className="py-3.5 px-4 text-right">
<button
onClick={() => openEditRisk(rk)}
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-700/60 rounded-lg transition cursor-pointer"
title="Edit Risk"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Modal: Define Indicator */}
<Modal
isOpen={isIndicatorModalOpen}
onClose={() => 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."
>
<form onSubmit={indicatorForm.handleSubmit((data) => indicatorMutation.mutate(data as CreateIndicatorRequest))} className="space-y-4">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Project *</label>
<HelpTooltip
title="Target Project"
content="The project against which this indicator is tracked."
size="sm"
placement="right"
/>
</div>
<select
{...indicatorForm.register('projectId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Indicator Statement *</label>
<HelpTooltip
title="Measurement Statement"
content="Precise description of the result or output being counted or verified."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...indicatorForm.register('name')}
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 transition"
placeholder="e.g. Smallholder farmers adopting precision drone analytics"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Unit of Measure *</label>
<HelpTooltip
title="Metric Dimension"
content="Counting unit (e.g. Number, Percentage, Currency ZAR, Rating Score)."
size="sm"
placement="top"
/>
</div>
<select
{...indicatorForm.register('unitOfMeasure')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{UNIT_OF_MEASURE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Baseline Value *</label>
<HelpTooltip
title="Pre-grant Baseline"
content="Observed initial starting value prior to project intervention."
size="sm"
placement="top"
/>
</div>
<input
type="number"
{...indicatorForm.register('baselineAmount')}
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"
/>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Target Value *</label>
<HelpTooltip
title="Target Benchmark"
content="Contracted goal to achieve before contract completion."
size="sm"
placement="top"
/>
</div>
<input
type="number"
{...indicatorForm.register('targetAmount')}
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"
/>
</div>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsIndicatorModalOpen(false)}
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
>
Cancel
</button>
<button
type="submit"
disabled={indicatorMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
>
{indicatorMutation.isPending ? 'Saving...' : 'Define Indicator'}
</button>
</div>
</form>
</Modal>
{/* Modal: Record Actual Amount */}
<Modal
isOpen={!!selectedIndicatorForActual}
onClose={() => 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."
>
<div className="space-y-4">
<div className="p-3.5 bg-slate-900/60 border border-slate-800/80 rounded-xl text-xs flex justify-between text-slate-300">
<span>Baseline: <strong className="text-white">{selectedIndicatorForActual?.baselineAmount}</strong></span>
<span>Target: <strong className="text-white">{selectedIndicatorForActual?.targetAmount}</strong></span>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Actual Value to Record *</label>
<HelpTooltip
title="Monitored Measurement"
content="Current realized value for this reporting period. Automatically updates to ≥90%, 70–89%, or <70% performance threshold status."
size="sm"
placement="right"
/>
</div>
<input
type="number"
value={actualInput}
onChange={(e) => 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"
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setSelectedIndicatorForActual(null)}
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
>
Cancel
</button>
<button
type="button"
onClick={() =>
selectedIndicatorForActual &&
actualMutation.mutate({
id: selectedIndicatorForActual.id,
amount: actualInput,
})
}
disabled={actualMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
>
{actualMutation.isPending ? 'Recording...' : 'Submit Actual Value'}
</button>
</div>
</div>
</Modal>
{/* Modal: Add or Edit Risk */}
<Modal
isOpen={isRiskModalOpen}
onClose={() => {
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."
>
<form onSubmit={riskForm.handleSubmit((data) => riskMutation.mutate(data))} className="space-y-4">
{!editingRisk && (
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Associated Project *</label>
<HelpTooltip
title="Context Project"
content="The project directly exposed to this potential threat or operational hazard."
size="sm"
placement="right"
/>
</div>
<select
{...riskForm.register('projectId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
)}
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Risk Title *</label>
<HelpTooltip
title="Event Name"
content="Succinct identifier of the uncertainty (e.g. Key Personnel Departure, Import Tariff Hike)."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...riskForm.register('name')}
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 transition"
placeholder="e.g. Foreign Exchange Currency Volatility"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Likelihood (1–5) *</label>
<HelpTooltip
title="Occurrence Probability"
content="Rated 1 (Rare) through 5 (Almost Certain)."
size="sm"
placement="top"
/>
</div>
<select
{...riskForm.register('likelihood')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{RISK_LIKELIHOOD_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Impact (1–5) *</label>
<HelpTooltip
title="Consequence Severity"
content="Rated 1 (Insignificant) through 5 (Catastrophic to schedule/budget)."
size="sm"
placement="top"
/>
</div>
<select
{...riskForm.register('impact')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{RISK_IMPACT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Risk Description *</label>
<HelpTooltip
title="Root Cause & Mitigation"
content="Detail underlying triggers, preventative actions, and contingency response steps."
size="sm"
placement="right"
/>
</div>
<textarea
rows={2}
{...riskForm.register('description')}
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 transition"
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsRiskModalOpen(false);
setEditingRisk(null);
}}
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
>
Cancel
</button>
<button
type="submit"
disabled={riskMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
>
{riskMutation.isPending ? 'Saving...' : editingRisk ? 'Update Risk' : 'Log Risk'}
</button>
</div>
</form>
</Modal>
</div>
);
};