Added PFM Console UI project
This commit is contained in:
@@ -0,0 +1,848 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
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,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getAllIndicators,
|
||||
createIndicator,
|
||||
recordIndicatorActual,
|
||||
getAllEvidence,
|
||||
uploadEvidenceFile,
|
||||
updateEvidence,
|
||||
getAllRisks,
|
||||
createRisk,
|
||||
getProjects,
|
||||
getAllMilestones,
|
||||
} from '../services/api';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import {
|
||||
Indicator,
|
||||
Evidence,
|
||||
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 MonitoringPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'indicators' | 'evidence' | 'risks'>('evidence');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Modals & States
|
||||
const [isIndicatorModalOpen, setIsIndicatorModalOpen] = useState(false);
|
||||
const [isRiskModalOpen, setIsRiskModalOpen] = useState(false);
|
||||
const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState<Indicator | null>(null);
|
||||
const [actualInput, setActualInput] = useState<number>(0);
|
||||
const [uploadEvidenceType, setUploadEvidenceType] = useState<number>(EvidenceType.CompletionCertificate);
|
||||
|
||||
// Figure 10 Reviewer commentary
|
||||
const [reviewerComment, setReviewerComment] = useState('');
|
||||
|
||||
// Queries
|
||||
const { data: indicators = [], isLoading: loadingIndicators } = useQuery({
|
||||
queryKey: ['indicators'],
|
||||
queryFn: () => getAllIndicators(),
|
||||
});
|
||||
|
||||
const { data: evidenceList = [], isLoading: loadingEvidence } = useQuery({
|
||||
queryKey: ['evidence'],
|
||||
queryFn: () => getAllEvidence(),
|
||||
});
|
||||
|
||||
const { data: risks = [], isLoading: loadingRisks } = 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) as any,
|
||||
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) as any,
|
||||
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', String(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', String(err));
|
||||
},
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (formData: FormData) => uploadEvidenceFile(formData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['evidence'] });
|
||||
success('Evidence uploaded', 'Supporting file registered for reviewer inspection (Figure 10 / M14)');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Upload failed', String(err));
|
||||
},
|
||||
});
|
||||
|
||||
const reviewEvidenceMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string | number; status: ApprovalStatus }) =>
|
||||
updateEvidence({
|
||||
evidenceId: id,
|
||||
version: 2,
|
||||
evidenceType: EvidenceType.DeliverableProof,
|
||||
status,
|
||||
}),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['evidence'] });
|
||||
if (variables.status === ApprovalStatus.Approved) {
|
||||
success(
|
||||
'Evidence Approved & Verified',
|
||||
'Approval recorded. Auto-triggers payment eligibility check in Disbursements.'
|
||||
);
|
||||
} else {
|
||||
success('Rework Requested', 'Evidence returned to recipient for correction.');
|
||||
}
|
||||
setReviewerComment('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Review update failed', String(err));
|
||||
},
|
||||
});
|
||||
|
||||
const riskMutation = useMutation({
|
||||
mutationFn: (data: CreateRiskRequest) => createRisk(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['risks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
success('Risk registered', 'Risk item added to project register');
|
||||
setIsRiskModalOpen(false);
|
||||
riskForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Failed to log risk', String(err));
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('projectId', '401');
|
||||
formData.append('milestoneId', '803');
|
||||
formData.append('evidenceType', String(uploadEvidenceType));
|
||||
uploadMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
// Indicator thresholds from SRS 9.6:
|
||||
// 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: 'Green (≥90%)', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' };
|
||||
if (pct >= 70) return { label: 'Amber (70–89%)', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20' };
|
||||
return { label: 'Red (<70%)', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' };
|
||||
};
|
||||
|
||||
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>
|
||||
<h2 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
|
||||
M&E, Evidence & Risk Management
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Verification workflow: Evidence Upload & Review Queue, Indicator Actuals Recording, and Risk Register.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{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"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Define Indicator
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'risks' && (
|
||||
<button
|
||||
onClick={() => setIsRiskModalOpen(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"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Risk to Register
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab('evidence')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${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'
|
||||
}`}
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
Milestone Evidence & Review Queue
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('indicators')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'indicators'
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Indicators & Actuals ({indicators.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('risks')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${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" />
|
||||
Risk Register & Heatmap ({risks.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab 1: Milestone Evidence Submission & Review (Exact Implementation of Figure 10) */}
|
||||
{activeTab === 'evidence' && (
|
||||
<div className="space-y-6">
|
||||
{/* Wireframe Container from SRS Figure 10 */}
|
||||
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||
{/* Header Bar */}
|
||||
<div className="bg-slate-900/90 text-white px-6 py-4 flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-slate-800/80">
|
||||
<span className="text-xs font-mono font-semibold tracking-wider text-blue-400 uppercase">
|
||||
RECIPIENT PORTAL • Project XYZ • Milestone 3: Prototype Delivery
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400 font-mono">Milestone Evidence Submission</span>
|
||||
</div>
|
||||
|
||||
{/* Workflow Progress Steps */}
|
||||
<div className="p-6 border-b border-slate-800/80 bg-slate-900/40">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 text-center text-xs font-semibold">
|
||||
<div className="py-2.5 px-3 rounded-xl bg-slate-800/60 text-slate-400 border border-slate-700/50">Planned</div>
|
||||
<div className="py-2.5 px-3 rounded-xl bg-slate-800/60 text-slate-400 border border-slate-700/50">In Progress</div>
|
||||
<div className="py-2.5 px-3 rounded-xl bg-blue-600 text-white shadow-lg shadow-blue-600/30 ring-2 ring-blue-500/40">
|
||||
Submitted
|
||||
</div>
|
||||
<div className="py-2.5 px-3 rounded-xl bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||
Under Review
|
||||
</div>
|
||||
<div className="py-2.5 px-3 rounded-xl bg-slate-900/40 text-slate-500 border border-dashed border-slate-800">
|
||||
Verified & Paid
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2 text-slate-300">
|
||||
<span className="font-semibold">Current status:</span>
|
||||
<span className="font-mono text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 font-bold">
|
||||
SUBMITTED — awaiting Programme Manager review
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Evidence & Attached Evidence Panels (Matching Figure 10) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-slate-800/80">
|
||||
{/* Upload Box */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-bold text-white">Upload Evidence</h4>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[11px] font-medium text-slate-400">Type:</label>
|
||||
<select
|
||||
value={uploadEvidenceType}
|
||||
onChange={(e) => setUploadEvidenceType(Number(e.target.value))}
|
||||
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 max-w-[180px] [&>option]:bg-slate-900 [&>option]:text-white"
|
||||
>
|
||||
{EVIDENCE_TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Drag & drop or browse files. Accepted formats: PDF, JPG, DOCX, XLSX (Max 25MB).
|
||||
</p>
|
||||
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-slate-700/80 hover:border-blue-500/80 bg-slate-900/40 hover:bg-blue-500/5 rounded-2xl p-8 text-center cursor-pointer transition flex flex-col items-center justify-center space-y-3"
|
||||
>
|
||||
<Upload className="w-8 h-8 text-slate-400" />
|
||||
<div>
|
||||
<span className="text-xs font-bold text-white block">Click to Browse or Drag Files Here</span>
|
||||
<span className="text-[10px] text-slate-500">Automated SHA-256 hash stamp applied on intake</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-1.5 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl shadow-xs transition"
|
||||
>
|
||||
Browse Files
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.docx,.xlsx,.zip"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attached Evidence List */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-bold text-white">Attached Evidence (v2)</h4>
|
||||
<span className="text-[11px] text-slate-400 font-mono">{evidenceList.length} files loaded</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{evidenceList.map((ev) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="p-3 rounded-xl border border-slate-800/80 bg-slate-900/60 flex items-center justify-between gap-3 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<FileText className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="font-semibold text-slate-200 block truncate font-mono">
|
||||
{ev.fileName || `evidence_file_${ev.id}.pdf`}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{ev.fileSize || '3.2 MB'} • v{ev.version} • {getEvidenceTypeLabel(ev.type)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||
Under Review
|
||||
</span>
|
||||
{ev.documentUrl && (
|
||||
<a
|
||||
href={ev.documentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-slate-400 hover:text-white p-1 transition"
|
||||
title="Download evidence file"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Programme Manager Review Section (Figure 10) */}
|
||||
<div className="p-6 border-t border-slate-800/80 bg-slate-900/40 space-y-4">
|
||||
<h4 className="text-sm font-bold text-white">Programme Manager Review</h4>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Reviewer comment / rejection reason
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reviewerComment}
|
||||
onChange={(e) => setReviewerComment(e.target.value)}
|
||||
placeholder="Mandatory if requesting rework; optional for approvals..."
|
||||
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="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
reviewEvidenceMutation.mutate({
|
||||
id: evidenceList[0]?.id || 1201,
|
||||
status: ApprovalStatus.Rejected,
|
||||
})
|
||||
}
|
||||
disabled={reviewEvidenceMutation.isPending}
|
||||
className="px-4 py-2 text-xs font-semibold text-white bg-rose-600/90 hover:bg-rose-600 rounded-xl shadow-lg shadow-rose-600/20 transition"
|
||||
>
|
||||
Reject / Rework
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
reviewEvidenceMutation.mutate({
|
||||
id: evidenceList[0]?.id || 1201,
|
||||
status: ApprovalStatus.Approved,
|
||||
})
|
||||
}
|
||||
disabled={reviewEvidenceMutation.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"
|
||||
>
|
||||
Approve & Verify
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-400 italic">
|
||||
Approval auto-triggers payment eligibility check.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 3: Risk Register (M09) */}
|
||||
{activeTab === 'risks' && (
|
||||
<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">Risk Title & Description</th>
|
||||
<th className="py-3.5 px-4">Project ID</th>
|
||||
<th className="py-3.5 px-4 text-center">Likelihood (1–5)</th>
|
||||
<th className="py-3.5 px-4 text-center">Impact (1–5)</th>
|
||||
<th className="py-3.5 px-4 text-center">Residual Score</th>
|
||||
<th className="py-3.5 px-4">Risk Owner</th>
|
||||
<th className="py-3.5 px-5">Mitigation Control</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{loadingRisks ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||
Loading risk register...
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
risks.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>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal: Define Indicator */}
|
||||
<Modal
|
||||
isOpen={isIndicatorModalOpen}
|
||||
onClose={() => setIsIndicatorModalOpen(false)}
|
||||
title="Define M&E Indicator"
|
||||
subtitle="Logical Framework & Theory of Change metric"
|
||||
>
|
||||
<form onSubmit={indicatorForm.handleSubmit((data) => indicatorMutation.mutate(data as any))} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Project *</label>
|
||||
<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>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Indicator Statement *</label>
|
||||
<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>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Unit of Measure *</label>
|
||||
<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>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Baseline Value *</label>
|
||||
<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>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Target Value *</label>
|
||||
<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 || ''}
|
||||
>
|
||||
<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>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Actual Value to Record *</label>
|
||||
<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 Risk */}
|
||||
<Modal
|
||||
isOpen={isRiskModalOpen}
|
||||
onClose={() => setIsRiskModalOpen(false)}
|
||||
title="Add Risk to Risk Register"
|
||||
subtitle="Identify and score project delivery risks"
|
||||
>
|
||||
<form onSubmit={riskForm.handleSubmit((data) => riskMutation.mutate(data as any))} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Associated Project *</label>
|
||||
<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>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Risk Title *</label>
|
||||
<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>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Likelihood (1–5) *</label>
|
||||
<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>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Impact (1–5) *</label>
|
||||
<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>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Risk Description *</label>
|
||||
<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)}
|
||||
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 ? 'Logging...' : 'Log Risk'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user