Removed settings page mock data and replaced with api calls
This commit is contained in:
@@ -22,6 +22,9 @@ import {
|
||||
Link as LinkIcon,
|
||||
Edit2,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Search,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getAllIndicators,
|
||||
@@ -36,6 +39,8 @@ import {
|
||||
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 { ContractsManagement } from '../components/monitoring/ContractsManagement';
|
||||
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
|
||||
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
||||
@@ -124,13 +129,34 @@ export const MonitoringPage: React.FC = () => {
|
||||
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 } = useQuery({
|
||||
const {
|
||||
data: indicators = [],
|
||||
isLoading: loadingIndicators,
|
||||
refetch: refetchIndicators,
|
||||
isFetching: isFetchingIndicators,
|
||||
} = useQuery({
|
||||
queryKey: ['indicators'],
|
||||
queryFn: () => getAllIndicators(),
|
||||
});
|
||||
|
||||
const { data: risks = [], isLoading: loadingRisks } = useQuery({
|
||||
const {
|
||||
data: risks = [],
|
||||
isLoading: loadingRisks,
|
||||
refetch: refetchRisks,
|
||||
isFetching: isFetchingRisks,
|
||||
} = useQuery({
|
||||
queryKey: ['risks'],
|
||||
queryFn: () => getAllRisks(),
|
||||
});
|
||||
@@ -264,21 +290,106 @@ export const MonitoringPage: React.FC = () => {
|
||||
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);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
|
||||
<span>M&E, Evidence & Risk Management</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold text-white tracking-tight">
|
||||
M&E, Evidence & Risk Management
|
||||
</h2>
|
||||
<HelpTooltip
|
||||
title="Monitoring & Evaluation Hub"
|
||||
content="Track performance indicators, verify tangible deliverable evidence against statutory milestones, and maintain proactive risk mitigation plans."
|
||||
placement="right"
|
||||
/>
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Verification workflow: Evidence Upload & Review Queue, Indicator Actuals Recording, and Risk Register.
|
||||
Verification workflow: Evidence Upload & Review Queue, Indicator Actuals Recording, and Risk Register.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -286,16 +397,16 @@ export const MonitoringPage: React.FC = () => {
|
||||
{activeTab === 'contracts' && (
|
||||
<button
|
||||
onClick={() => setIsCreateContractModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 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 cursor-pointer"
|
||||
className="inline-flex items-center gap-1.5 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-md shadow-blue-500/20 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Contract & Upload PDF
|
||||
Create Contract & Upload PDF
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'indicators' && (
|
||||
<button
|
||||
onClick={() => setIsIndicatorModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 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 cursor-pointer"
|
||||
className="inline-flex items-center gap-1.5 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-md shadow-blue-500/20 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Define Indicator
|
||||
@@ -304,7 +415,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
{activeTab === 'risks' && (
|
||||
<button
|
||||
onClick={openCreateRisk}
|
||||
className="inline-flex items-center gap-1.5 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 cursor-pointer"
|
||||
className="inline-flex items-center gap-1.5 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-md shadow-blue-500/20 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Risk to Register
|
||||
@@ -405,63 +516,196 @@ export const MonitoringPage: React.FC = () => {
|
||||
|
||||
{/* Tab 2: Indicators Table (M08) */}
|
||||
{activeTab === 'indicators' && (
|
||||
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||
<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">
|
||||
<th className="py-3.5 px-5">Indicator Name</th>
|
||||
<th className="py-3.5 px-4">Project ID</th>
|
||||
<th className="py-3.5 px-4">Unit of Measure</th>
|
||||
<th className="py-3.5 px-4 text-right">Baseline</th>
|
||||
<th className="py-3.5 px-4 text-right">Target</th>
|
||||
<th className="py-3.5 px-4 text-right">Actual Recorded</th>
|
||||
<th className="py-3.5 px-4">Status</th>
|
||||
<th className="py-3.5 px-5 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{loadingIndicators ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-8 text-center text-slate-500">
|
||||
Loading indicators...
|
||||
</td>
|
||||
<div className="space-y-4">
|
||||
{/* Indicators Search and 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 indicators, statement, project ID..."
|
||||
value={indicatorSearchTerm}
|
||||
onChange={(e) => setIndicatorSearchTerm(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={indicatorProjectFilter}
|
||||
onChange={(e) => setIndicatorProjectFilter(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>
|
||||
|
||||
{/* Status 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" />
|
||||
Status:
|
||||
</label>
|
||||
<select
|
||||
value={indicatorStatusFilter}
|
||||
onChange={(e) => setIndicatorStatusFilter(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 Statuses</option>
|
||||
<option value="green">≥90% Target Achieved</option>
|
||||
<option value="amber">70–89% On Track</option>
|
||||
<option value="red"><70% Underperforming</option>
|
||||
<option value="pending">Pending Actuals</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Unit Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-slate-400 whitespace-nowrap font-medium">Unit:</label>
|
||||
<select
|
||||
value={indicatorUnitFilter}
|
||||
onChange={(e) => setIndicatorUnitFilter(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 Units</option>
|
||||
{UNIT_OF_MEASURE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorStatusFilter !== 'all' || indicatorUnitFilter !== 'all') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIndicatorSearchTerm('');
|
||||
setIndicatorProjectFilter('all');
|
||||
setIndicatorStatusFilter('all');
|
||||
setIndicatorUnitFilter('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={() => {
|
||||
refetchIndicators();
|
||||
success('Refreshed', 'Latest performance metrics loaded from API.');
|
||||
}}
|
||||
disabled={isFetchingIndicators}
|
||||
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 indicators from API"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingIndicators ? 'animate-spin' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
title="M&E Performance Indicators"
|
||||
subtitle="Baseline and target metrics tracking for monitoring and evaluation"
|
||||
filename={`pfm-indicators-${new Date().toISOString().split('T')[0]}`}
|
||||
columns={indicatorExportColumns}
|
||||
data={filteredIndicators}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicators 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">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-blue-400" />
|
||||
Performance Indicators & Actuals Ledger
|
||||
</h3>
|
||||
<span className="text-xs text-slate-400 font-mono">
|
||||
Showing {filteredIndicators.length} of {indicators.length} indicators
|
||||
</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">
|
||||
<th className="py-3.5 px-5">Indicator Name</th>
|
||||
<th className="py-3.5 px-4">Project ID</th>
|
||||
<th className="py-3.5 px-4">Unit of Measure</th>
|
||||
<th className="py-3.5 px-4 text-right">Baseline</th>
|
||||
<th className="py-3.5 px-4 text-right">Target</th>
|
||||
<th className="py-3.5 px-4 text-right">Actual Recorded</th>
|
||||
<th className="py-3.5 px-4">Status</th>
|
||||
<th className="py-3.5 px-5 text-right">Action</th>
|
||||
</tr>
|
||||
) : (
|
||||
indicators.map((ind) => {
|
||||
const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount);
|
||||
return (
|
||||
<tr key={ind.id} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5 font-semibold text-white">{ind.name}</td>
|
||||
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{ind.projectId}</td>
|
||||
<td className="py-3.5 px-4 text-slate-300">{getUnitOfMeasureLabel(ind.unitOfMeasure)}</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono text-slate-400">{ind.baselineAmount}</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">{ind.targetAmount}</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-emerald-400 text-xs">
|
||||
{ind.actualAmount}
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusObj.badge}`}>
|
||||
{statusObj.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedIndicatorForActual(ind);
|
||||
setActualInput(ind.actualAmount);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 px-3 py-1.5 rounded-lg border border-slate-700/60 transition"
|
||||
>
|
||||
Record Actual
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{loadingIndicators ? (
|
||||
<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 project indicators from API...
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredIndicators.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||
<TrendingUp className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||
{indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorStatusFilter !== 'all' || indicatorUnitFilter !== 'all'
|
||||
? 'No indicators match your filter criteria.'
|
||||
: 'No performance indicators defined yet. Click "Define Indicator" to begin.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredIndicators.map((ind) => {
|
||||
const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount);
|
||||
return (
|
||||
<tr key={ind.id} className="hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-5 font-semibold text-white">{ind.name}</td>
|
||||
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{ind.projectId}</td>
|
||||
<td className="py-3.5 px-4 text-slate-300">{getUnitOfMeasureLabel(ind.unitOfMeasure)}</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono text-slate-400">{ind.baselineAmount}</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">{ind.targetAmount}</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-emerald-400 text-xs">
|
||||
{ind.actualAmount}
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusObj.badge}`}>
|
||||
{statusObj.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedIndicatorForActual(ind);
|
||||
setActualInput(ind.actualAmount);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 px-3 py-1.5 rounded-lg border border-slate-700/60 transition cursor-pointer"
|
||||
>
|
||||
Record Actual
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -478,20 +722,112 @@ export const MonitoringPage: React.FC = () => {
|
||||
subtitle="ISO 31000 standard matrix — click on any cell to filter and inspect risks or edit mitigation controls"
|
||||
/>
|
||||
|
||||
{/* Risk Register Table */}
|
||||
{/* 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
|
||||
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 bg-slate-900/60">
|
||||
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-white">Project Risk Register</h4>
|
||||
<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>
|
||||
<button
|
||||
onClick={openCreateRisk}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition shadow-md shadow-blue-500/20"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add Risk
|
||||
</button>
|
||||
<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">
|
||||
@@ -510,18 +846,22 @@ export const MonitoringPage: React.FC = () => {
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{loadingRisks ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-8 text-center text-slate-500">
|
||||
Loading risk register...
|
||||
<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>
|
||||
) : risks.length === 0 ? (
|
||||
) : filteredRisks.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-8 text-center text-slate-500">
|
||||
No risks recorded in the register. Click "Add Risk" to log your first risk item.
|
||||
<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>
|
||||
) : (
|
||||
risks.map((rk) => {
|
||||
filteredRisks.map((rk) => {
|
||||
const score = rk.likelihood * rk.impact;
|
||||
const color = getRiskScoreColor(rk.likelihood, rk.impact);
|
||||
return (
|
||||
@@ -545,7 +885,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
<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"
|
||||
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" />
|
||||
|
||||
Reference in New Issue
Block a user