Improved compliance monitoring

This commit is contained in:
2026-10-22 15:42:36 +02:00
parent 9835121fd9
commit 299d6ff4dc
27 changed files with 4245 additions and 149 deletions
@@ -0,0 +1,510 @@
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">&bull;</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>
);
};