1055 lines
46 KiB
TypeScript
1055 lines
46 KiB
TypeScript
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&E, Evidence & 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 & 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>
|
||
);
|
||
};
|