Improved compliance monitoring
This commit is contained in:
@@ -91,6 +91,13 @@ const navItems: NavItem[] = [
|
||||
help: 'Log physical site visits, field observations, inspector findings, and project feedback.',
|
||||
claim: 'site_visits',
|
||||
},
|
||||
{
|
||||
name: 'Compliance',
|
||||
path: '/compliance',
|
||||
icon: ShieldCheck,
|
||||
help: 'Portfolio & programme compliance dashboard, statutory requirements, beneficiary outcome fulfillment, and risk monitoring.',
|
||||
claim: 'compliance',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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">•</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>
|
||||
);
|
||||
};
|
||||
@@ -214,53 +214,78 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
title={`Milestones & Deliverables: ${project.name}`}
|
||||
subtitle={`Project #${project.id} • Statutory Work Breakdown Schedule`}
|
||||
helpText="Contractual milestones represent formal deliverables. The monetary value of each milestone is dynamically aggregated from its scheduled activities vs indicator actuals. The project's total value is the sum of all milestones."
|
||||
maxWidth="xl"
|
||||
maxWidth="4xl"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* KPI Cards: Project Value Roll-Up & Performance */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
<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-blue-400" />
|
||||
Total Project Value
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-blue-400">{formatZAR(totalProjectMilestonesValue)}</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
||||
<Coins className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||
Total Project Value
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500">Sum of all milestones</span>
|
||||
<div className="my-1.5">
|
||||
<div
|
||||
className="text-lg sm:text-xl font-bold font-mono text-blue-400 truncate tracking-tight"
|
||||
title={formatZAR(totalProjectMilestonesValue)}
|
||||
>
|
||||
{formatZAR(totalProjectMilestonesValue)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 truncate">Sum of all milestones</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" />
|
||||
Approved Budget
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-emerald-400">{formatZAR(approvedBudgetTotal)}</span>
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
||||
<Coins className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||
Approved Budget
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500">Allocated budget ceiling</span>
|
||||
<div className="my-1.5">
|
||||
<div
|
||||
className="text-lg sm:text-xl font-bold font-mono text-emerald-400 truncate tracking-tight"
|
||||
title={formatZAR(approvedBudgetTotal)}
|
||||
>
|
||||
{formatZAR(approvedBudgetTotal)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 truncate">Allocated budget ceiling</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">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-cyan-400" />
|
||||
Partner Actuals
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold font-mono text-cyan-400">{formatZAR(totalProjectActuals)}</span>
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
||||
Partner Actuals
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500">Incurred material costs</span>
|
||||
<div className="my-1.5">
|
||||
<div
|
||||
className="text-lg sm:text-xl font-bold font-mono text-cyan-400 truncate tracking-tight"
|
||||
title={formatZAR(totalProjectActuals)}
|
||||
>
|
||||
{formatZAR(totalProjectActuals)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 truncate">Incurred material costs</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">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400" />
|
||||
Milestone Progress
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-bold text-white font-mono">{progressPct}%</span>
|
||||
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800 hover:border-slate-700 transition flex flex-col justify-between min-w-0 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5 truncate">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400 shrink-0" />
|
||||
Milestone Progress
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
<div className="my-1.5">
|
||||
<div className="text-lg sm:text-xl font-bold text-white font-mono truncate tracking-tight">
|
||||
{progressPct}%
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 truncate">
|
||||
{verifiedCount} of {milestones.length} verified
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -239,14 +239,26 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
||||
{formatDate(project.startedAt)} → {formatDate(project.endedAt)} ({durationDays} Days)
|
||||
</span>
|
||||
</div>
|
||||
{approvedBudgetTotal > 0 && (
|
||||
<>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="text-emerald-400 font-mono font-bold">
|
||||
Budget: {formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="text-emerald-400 font-mono font-bold">
|
||||
Budget: {formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="text-blue-400 font-mono font-bold">
|
||||
Project Value: {formatZAR(totalActivityBudget)}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span
|
||||
className={`font-mono font-bold px-2 py-0.5 rounded text-[11px] border ${
|
||||
approvedBudgetTotal - totalActivityBudget >= 0
|
||||
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
||||
}`}
|
||||
title="Approved Budget minus Total Project Value"
|
||||
>
|
||||
Variance: {approvedBudgetTotal - totalActivityBudget >= 0 ? '+' : ''}
|
||||
{formatZAR(approvedBudgetTotal - totalActivityBudget)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -505,6 +517,16 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
||||
</span>
|
||||
<span className="text-slate-500">#{m.id}</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const mActivities = activities.filter((a) => String(a.milestoneId) === String(m.id));
|
||||
const mValue = mActivities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
return (
|
||||
<div className="flex items-center justify-between text-[10px] font-mono pt-1 border-t border-slate-800/60">
|
||||
<span className="text-slate-400">Milestone Value:</span>
|
||||
<span className="font-bold text-blue-400">{formatZAR(mValue)}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -549,10 +571,33 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
||||
key={act.id}
|
||||
className="p-2.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs space-y-1 hover:border-slate-700 transition"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-semibold text-slate-200 truncate block">{act.title}</span>
|
||||
<span className="font-mono text-cyan-400 font-bold shrink-0">{formatZAR(act.amount)}</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const actAmount = Number(act.amount) || 0;
|
||||
const actIndicators = indicators.filter((ind) => String(ind.activityId) === String(act.id));
|
||||
const actActuals = actIndicators.reduce((sum, ind) => sum + (Number(ind.actualCost) || 0), 0);
|
||||
const actVariance = actAmount - actActuals;
|
||||
const isPositive = actVariance >= 0;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-semibold text-slate-200 truncate block">{act.title}</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0 font-mono text-xs">
|
||||
<span className="font-bold text-cyan-400">{formatZAR(actAmount)}</span>
|
||||
<span
|
||||
className={`text-[9px] font-bold px-1.5 py-0.2 rounded border ${
|
||||
isPositive
|
||||
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
||||
}`}
|
||||
title={`Budgeted: ${formatZAR(actAmount)} | Incurred Actuals: ${formatZAR(actActuals)} | Variance: ${isPositive ? '+' : ''}${formatZAR(actVariance)}`}
|
||||
>
|
||||
{isPositive ? `+${formatZAR(actVariance)}` : `-${formatZAR(Math.abs(actVariance))}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-400">
|
||||
<span>{act.category || 'Operations'}</span>
|
||||
<span>{formatDate(act.performedAt)}</span>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import { ApprovalStatus, Priority, ActivityStatus, UserRole } from '../../types';
|
||||
import { ApprovalStatus, Priority, ActivityStatus, UserRole, ComplianceStatus } from '../../types';
|
||||
import {
|
||||
getApprovalStatusBadge,
|
||||
getPriorityBadge,
|
||||
getActivityStatusBadge,
|
||||
getUserRoleBadge,
|
||||
getComplianceStatusBadge,
|
||||
} from '../../lib/formatters';
|
||||
|
||||
export const ApprovalStatusBadge: React.FC<{ status: ApprovalStatus | number | null | undefined }> = ({ status }) => {
|
||||
@@ -17,6 +18,16 @@ export const ApprovalStatusBadge: React.FC<{ status: ApprovalStatus | number | n
|
||||
);
|
||||
};
|
||||
|
||||
export const ComplianceStatusBadge: React.FC<{ status: ComplianceStatus | number | null | undefined }> = ({ status }) => {
|
||||
const badge = getComplianceStatusBadge(status ?? ComplianceStatus.NotMet);
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold border whitespace-nowrap ${badge.className}`}>
|
||||
{badge.dot && <span className={`w-1.5 h-1.5 rounded-full ${badge.dot}`} />}
|
||||
<span>{badge.label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const PriorityBadge: React.FC<{ priority: Priority | number | null | undefined }> = ({ priority }) => {
|
||||
const badge = getPriorityBadge(priority ?? Priority.Medium);
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user