511 lines
22 KiB
TypeScript
511 lines
22 KiB
TypeScript
import React from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import {
|
|
ShieldCheck,
|
|
ShieldAlert,
|
|
AlertTriangle,
|
|
Users,
|
|
CheckCircle2,
|
|
TrendingUp,
|
|
Target,
|
|
Clock,
|
|
Coins,
|
|
AlertCircle,
|
|
Sparkles,
|
|
} from 'lucide-react';
|
|
import { Modal } from '../ui/Modal';
|
|
import { HelpTooltip } from '../ui/HelpTooltip';
|
|
import { ComplianceStatusBadge } from '../ui/StatusBadge';
|
|
import {
|
|
Project,
|
|
Programme,
|
|
ComplianceItem,
|
|
Beneficiary,
|
|
Outcome,
|
|
ComplianceStatus,
|
|
ApprovalStatus,
|
|
} from '../../types';
|
|
import {
|
|
getProjectComplianceItems,
|
|
getProjectBeneficiaries,
|
|
getBeneficiaryOutcomes,
|
|
getProjectRisks,
|
|
getMilestones,
|
|
getActivities,
|
|
getBudgets,
|
|
} from '../../services/api';
|
|
import {
|
|
formatDate,
|
|
formatZAR,
|
|
getRiskScoreColor,
|
|
getIndustrySectorLabel,
|
|
getProjectTypeLabel,
|
|
} from '../../lib/formatters';
|
|
|
|
interface ProjectComplianceModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
project: Project | null;
|
|
programme: Programme | null;
|
|
}
|
|
|
|
export const ProjectComplianceModal: React.FC<ProjectComplianceModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
project,
|
|
programme,
|
|
}) => {
|
|
const projectId = project?.id;
|
|
|
|
// Queries
|
|
const { data: complianceItems = [], isLoading: loadingCompliance } = useQuery({
|
|
queryKey: ['complianceItems', projectId],
|
|
queryFn: () => (projectId ? getProjectComplianceItems(projectId) : Promise.resolve([])),
|
|
enabled: isOpen && !!projectId,
|
|
});
|
|
|
|
const { data: projectBeneficiaries = [], isLoading: loadingBeneficiaries } = useQuery({
|
|
queryKey: ['projectBeneficiaries', projectId],
|
|
queryFn: () => (projectId ? getProjectBeneficiaries(projectId) : Promise.resolve([])),
|
|
enabled: isOpen && !!projectId,
|
|
});
|
|
|
|
const { data: risks = [], isLoading: loadingRisks } = useQuery({
|
|
queryKey: ['risks', projectId],
|
|
queryFn: () => (projectId ? getProjectRisks(projectId) : Promise.resolve([])),
|
|
enabled: isOpen && !!projectId,
|
|
});
|
|
|
|
const { data: milestones = [] } = useQuery({
|
|
queryKey: ['milestones', projectId],
|
|
queryFn: () => (projectId ? getMilestones(projectId) : Promise.resolve([])),
|
|
enabled: isOpen && !!projectId,
|
|
});
|
|
|
|
const { data: activities = [] } = useQuery({
|
|
queryKey: ['activities', projectId],
|
|
queryFn: () => (projectId ? getActivities(projectId) : Promise.resolve([])),
|
|
enabled: isOpen && !!projectId,
|
|
});
|
|
|
|
const { data: allBudgets = [] } = useQuery({
|
|
queryKey: ['budgets'],
|
|
queryFn: () => getBudgets(1, 100),
|
|
enabled: isOpen && !!projectId,
|
|
});
|
|
|
|
// Beneficiaries already filtered by project
|
|
|
|
// Beneficiary outcomes query
|
|
const { data: outcomes = [], isLoading: loadingOutcomes } = useQuery({
|
|
queryKey: ['beneficiaryOutcomes', projectBeneficiaries.map((b) => b.id)],
|
|
queryFn: async (): Promise<Outcome[]> => {
|
|
if (projectBeneficiaries.length === 0) return [];
|
|
const results = await Promise.all(
|
|
projectBeneficiaries.map(async (b) => {
|
|
try {
|
|
return await getBeneficiaryOutcomes(b.id);
|
|
} catch {
|
|
return [];
|
|
}
|
|
})
|
|
);
|
|
return results.flat();
|
|
},
|
|
enabled: isOpen && projectBeneficiaries.length > 0,
|
|
});
|
|
|
|
if (!project) return null;
|
|
|
|
// Compliance calculations
|
|
const totalCompCount = complianceItems.length;
|
|
const metCompCount = complianceItems.filter(
|
|
(c) => Number(c.status) === ComplianceStatus.Met || Number(c.status) === ComplianceStatus.Exceeded
|
|
).length;
|
|
const unmetCompCount = totalCompCount - metCompCount;
|
|
|
|
// Outcomes calculations
|
|
const totalOutcomesCount = outcomes.length;
|
|
const metOutcomesCount = outcomes.filter(
|
|
(o) => Number(o.status) === ComplianceStatus.Met || Number(o.status) === ComplianceStatus.Exceeded
|
|
).length;
|
|
const unmetOutcomesCount = totalOutcomesCount - metOutcomesCount;
|
|
|
|
// Overall compliance verdict (Invariant: 100% met required for approval)
|
|
const is100PercentCompliant =
|
|
unmetCompCount === 0 && unmetOutcomesCount === 0;
|
|
|
|
// Financial calculations
|
|
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(projectId));
|
|
const approvedBudgetTotal = projectBudgets
|
|
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
|
|
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
|
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
|
const financialVariance = approvedBudgetTotal - totalActivityBudget;
|
|
const isWithinBudget = financialVariance >= 0;
|
|
|
|
// Milestone Progress
|
|
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
|
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
title={`Risk & Compliance Status: ${project.name}`}
|
|
subtitle={`Project #${project.id} • ${programme?.name || 'Programme'} • Statutory Governance Audit`}
|
|
helpText="Comprehensive audit of project compliance items, beneficiary outcomes fulfillment, ISO 31000 risk register, and statutory approval eligibility."
|
|
maxWidth="5xl"
|
|
>
|
|
<div className="space-y-6 text-slate-200">
|
|
{/* Top Verdict Banner */}
|
|
<div
|
|
className={`p-4 sm:p-5 rounded-2xl border flex flex-col sm:flex-row sm:items-center justify-between gap-4 ${
|
|
is100PercentCompliant
|
|
? 'bg-emerald-950/30 border-emerald-500/40'
|
|
: 'bg-rose-950/30 border-rose-500/40'
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div
|
|
className={`p-2.5 rounded-xl border mt-0.5 ${
|
|
is100PercentCompliant
|
|
? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'
|
|
: 'bg-rose-500/20 text-rose-400 border-rose-500/30'
|
|
}`}
|
|
>
|
|
{is100PercentCompliant ? (
|
|
<ShieldCheck className="w-6 h-6" />
|
|
) : (
|
|
<ShieldAlert className="w-6 h-6" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h4 className="text-sm font-bold text-white">
|
|
{is100PercentCompliant
|
|
? '100% Fully Compliant — Approved for Project Governance'
|
|
: 'Action Required: Project is Noncompliant'}
|
|
</h4>
|
|
<span
|
|
className={`text-[10px] uppercase font-bold px-2 py-0.5 rounded-full border ${
|
|
is100PercentCompliant
|
|
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40'
|
|
: 'bg-rose-500/20 text-rose-300 border-rose-500/40 animate-pulse'
|
|
}`}
|
|
>
|
|
{is100PercentCompliant ? 'Eligible for Approval' : 'Approvals Blocked'}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-slate-300 mt-1">
|
|
{is100PercentCompliant
|
|
? 'All statutory compliance requirements and beneficiary impact outcomes have met or exceeded threshold requirements.'
|
|
: `This project has ${unmetCompCount} unmet compliance item(s) and ${unmetOutcomesCount} unmet beneficiary outcome(s). Per governance policy, projects cannot be approved until all compliance items and outcomes are 100% met.`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shrink-0 flex items-center gap-3">
|
|
<div className="text-right">
|
|
<span className="text-[10px] text-slate-400 block uppercase font-semibold">Budget At Risk</span>
|
|
<span className={`text-base font-bold font-mono ${is100PercentCompliant ? 'text-emerald-400' : 'text-rose-400'}`}>
|
|
{is100PercentCompliant ? 'R 0.00' : formatZAR(approvedBudgetTotal)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 4 Summary Metric Cards */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
|
|
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
|
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
|
<ShieldCheck className="w-3.5 h-3.5 text-cyan-400" />
|
|
Compliance Requirements
|
|
</span>
|
|
<div className="flex items-baseline gap-2 mt-1">
|
|
<span className="text-xl font-bold font-mono text-cyan-400">
|
|
{metCompCount}/{totalCompCount}
|
|
</span>
|
|
<span className="text-xs text-slate-400 font-semibold">
|
|
({totalCompCount > 0 ? Math.round((metCompCount / totalCompCount) * 100) : 100}%)
|
|
</span>
|
|
</div>
|
|
<span className="text-[10px] text-slate-500">
|
|
{unmetCompCount === 0 ? 'All items satisfied' : `${unmetCompCount} pending fulfillment`}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
|
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
|
<Target className="w-3.5 h-3.5 text-purple-400" />
|
|
Beneficiary Outcomes
|
|
</span>
|
|
<div className="flex items-baseline gap-2 mt-1">
|
|
<span className="text-xl font-bold font-mono text-purple-400">
|
|
{metOutcomesCount}/{totalOutcomesCount}
|
|
</span>
|
|
<span className="text-xs text-slate-400 font-semibold">
|
|
({totalOutcomesCount > 0 ? Math.round((metOutcomesCount / totalOutcomesCount) * 100) : 100}%)
|
|
</span>
|
|
</div>
|
|
<span className="text-[10px] text-slate-500">
|
|
Across {projectBeneficiaries.length} beneficiary group{projectBeneficiaries.length === 1 ? '' : 's'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
|
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
|
<Coins className="w-3.5 h-3.5 text-emerald-400" />
|
|
Financial Variance
|
|
</span>
|
|
<div className="flex items-baseline gap-2 mt-1">
|
|
<span
|
|
className={`text-xl font-bold font-mono ${
|
|
isWithinBudget ? 'text-emerald-400' : 'text-rose-400'
|
|
}`}
|
|
>
|
|
{isWithinBudget ? `+${formatZAR(financialVariance)}` : `-${formatZAR(Math.abs(financialVariance))}`}
|
|
</span>
|
|
</div>
|
|
<span className="text-[10px] text-slate-500">
|
|
Budget: {formatZAR(approvedBudgetTotal)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="bg-[#0b101c] p-3.5 rounded-xl border border-slate-800">
|
|
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
|
|
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
|
|
Active Project Risks
|
|
</span>
|
|
<div className="flex items-baseline gap-2 mt-1">
|
|
<span className="text-xl font-bold font-mono text-amber-400">{risks.length}</span>
|
|
<span className="text-xs text-slate-400">
|
|
({risks.filter((r) => (Number(r.likelihood) || 1) * (Number(r.impact) || 1) >= 12).length} High)
|
|
</span>
|
|
</div>
|
|
<span className="text-[10px] text-slate-500">ISO 31000 risk register</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section 1: Statutory Compliance Requirements */}
|
|
<div className="bg-[#0b101c] rounded-2xl border border-slate-800 overflow-hidden shadow-sm">
|
|
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-[#0d1322]">
|
|
<div className="flex items-center gap-2">
|
|
<ShieldCheck className="w-4 h-4 text-cyan-400" />
|
|
<h4 className="text-xs font-bold uppercase tracking-wider text-white">
|
|
Statutory Compliance Requirements ({complianceItems.length})
|
|
</h4>
|
|
</div>
|
|
<span className="text-[11px] text-slate-400">
|
|
{metCompCount} of {complianceItems.length} Met / Exceeded
|
|
</span>
|
|
</div>
|
|
|
|
{loadingCompliance ? (
|
|
<div className="p-8 text-center text-xs text-slate-400">Loading compliance register...</div>
|
|
) : complianceItems.length === 0 ? (
|
|
<div className="p-6 text-center text-xs text-slate-500">
|
|
No specific statutory compliance requirements captured for this project yet.
|
|
</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 text-slate-400 text-[11px] font-semibold">
|
|
<th className="py-2.5 px-4">Title</th>
|
|
<th className="py-2.5 px-4">Requirements Specification</th>
|
|
<th className="py-2.5 px-4">Linked Beneficiary</th>
|
|
<th className="py-2.5 px-4">Status</th>
|
|
<th className="py-2.5 px-4 text-right">Created</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-800/60">
|
|
{complianceItems.map((ci) => {
|
|
const linkedBen = projectBeneficiaries.find(
|
|
(b) => String(b.id) === String(ci.beneficiaryId)
|
|
);
|
|
return (
|
|
<tr key={ci.id} className="hover:bg-slate-900/30 transition">
|
|
<td className="py-2.5 px-4 font-semibold text-white">{ci.title}</td>
|
|
<td className="py-2.5 px-4 text-slate-300 max-w-sm truncate" title={ci.requirements || ''}>
|
|
{ci.requirements || 'N/A'}
|
|
</td>
|
|
<td className="py-2.5 px-4 text-slate-300">
|
|
{linkedBen ? (
|
|
<span className="px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300 text-[11px]">
|
|
{linkedBen.name}
|
|
</span>
|
|
) : (
|
|
<span className="text-slate-500 italic">Project-Wide</span>
|
|
)}
|
|
</td>
|
|
<td className="py-2.5 px-4">
|
|
<ComplianceStatusBadge status={ci.status} />
|
|
</td>
|
|
<td className="py-2.5 px-4 text-right text-slate-400 font-mono text-[11px]">
|
|
{formatDate(ci.createdAt)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Section 2: Affected Beneficiaries & Mapped Outcomes */}
|
|
<div className="bg-[#0b101c] rounded-2xl border border-slate-800 overflow-hidden shadow-sm">
|
|
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-[#0d1322]">
|
|
<div className="flex items-center gap-2">
|
|
<Users className="w-4 h-4 text-blue-400" />
|
|
<h4 className="text-xs font-bold uppercase tracking-wider text-white">
|
|
Affected Beneficiaries & Mapped Impact Outcomes ({projectBeneficiaries.length})
|
|
</h4>
|
|
</div>
|
|
<span className="text-[11px] text-slate-400">
|
|
Total Target: {projectBeneficiaries.reduce((sum, b) => sum + (Number(b.targetCount) || 0), 0)} Reached:{' '}
|
|
{projectBeneficiaries.reduce((sum, b) => sum + (Number(b.reachedCount) || 0), 0)}
|
|
</span>
|
|
</div>
|
|
|
|
{loadingBeneficiaries || loadingOutcomes ? (
|
|
<div className="p-8 text-center text-xs text-slate-400">Loading beneficiaries & outcomes...</div>
|
|
) : projectBeneficiaries.length === 0 ? (
|
|
<div className="p-6 text-center text-xs text-slate-500">
|
|
No target beneficiaries mapped to this project yet.
|
|
</div>
|
|
) : (
|
|
<div className="p-4 space-y-4">
|
|
{projectBeneficiaries.map((b) => {
|
|
const benOutcomes = outcomes.filter((o) => String(o.beneficiaryId) === String(b.id));
|
|
return (
|
|
<div
|
|
key={b.id}
|
|
className="p-3.5 rounded-xl bg-slate-900/60 border border-slate-800 space-y-3"
|
|
>
|
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
<div>
|
|
<span className="font-bold text-white text-xs mr-2">{b.name}</span>
|
|
<span className="text-[11px] text-slate-400 bg-slate-800 px-2 py-0.5 rounded border border-slate-700/60">
|
|
{b.category} • {b.location}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-xs font-mono">
|
|
<span className="text-slate-400">
|
|
Target: <strong className="text-white">{b.targetCount}</strong>
|
|
</span>
|
|
<span className="text-slate-600">•</span>
|
|
<span className="text-slate-400">
|
|
Reached: <strong className="text-emerald-400">{b.reachedCount}</strong>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mapped Outcomes */}
|
|
<div className="pt-2 border-t border-slate-800/80">
|
|
<span className="text-[10px] uppercase font-bold text-slate-400 block mb-1.5">
|
|
Mapped Expected Outcomes ({benOutcomes.length})
|
|
</span>
|
|
{benOutcomes.length === 0 ? (
|
|
<p className="text-[11px] text-slate-500 italic">No expected outcomes recorded for this group.</p>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
{benOutcomes.map((o) => (
|
|
<div
|
|
key={o.id}
|
|
className="p-2.5 rounded-lg bg-slate-950/60 border border-slate-800/80 flex items-start justify-between gap-2"
|
|
>
|
|
<div className="min-w-0">
|
|
<span className="font-semibold text-slate-200 text-xs block truncate">{o.name}</span>
|
|
<span className="text-[10px] text-slate-400 line-clamp-1">{o.description}</span>
|
|
</div>
|
|
<ComplianceStatusBadge status={o.status} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Section 3: Risk Profile (ISO 31000) */}
|
|
<div className="bg-[#0b101c] rounded-2xl border border-slate-800 overflow-hidden shadow-sm">
|
|
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-[#0d1322]">
|
|
<div className="flex items-center gap-2">
|
|
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
|
<h4 className="text-xs font-bold uppercase tracking-wider text-white">
|
|
Active Project Risks Register ({risks.length})
|
|
</h4>
|
|
</div>
|
|
<span className="text-[11px] text-slate-400">ISO 31000 Risk Management</span>
|
|
</div>
|
|
|
|
{loadingRisks ? (
|
|
<div className="p-8 text-center text-xs text-slate-400">Loading risk register...</div>
|
|
) : risks.length === 0 ? (
|
|
<div className="p-6 text-center text-xs text-slate-500">
|
|
No active risks logged on this project register.
|
|
</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 text-slate-400 text-[11px] font-semibold">
|
|
<th className="py-2.5 px-4">Risk Title</th>
|
|
<th className="py-2.5 px-4">Category</th>
|
|
<th className="py-2.5 px-4 text-center">Score</th>
|
|
<th className="py-2.5 px-4">Mitigation Strategy</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-800/60">
|
|
{risks.map((r) => {
|
|
const score = (Number(r.likelihood) || 1) * (Number(r.impact) || 1);
|
|
return (
|
|
<tr key={r.id} className="hover:bg-slate-900/30 transition">
|
|
<td className="py-2.5 px-4 font-semibold text-white">{r.title}</td>
|
|
<td className="py-2.5 px-4 text-slate-300">{r.category || 'Operational'}</td>
|
|
<td className="py-2.5 px-4 text-center">
|
|
<span
|
|
className={`px-2 py-0.5 rounded text-[10px] font-bold ${
|
|
score >= 12
|
|
? 'bg-rose-500/20 text-rose-300 border border-rose-500/30'
|
|
: score >= 8
|
|
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
|
|
: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
|
|
}`}
|
|
>
|
|
Score: {score}
|
|
</span>
|
|
</td>
|
|
<td className="py-2.5 px-4 text-slate-300 max-w-sm truncate" title={r.mitigation || ''}>
|
|
{r.mitigation || 'No mitigation recorded'}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer Actions */}
|
|
<div className="flex justify-end pt-3 border-t border-slate-800">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 transition"
|
|
>
|
|
Close Audit Inspector
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|