530 lines
23 KiB
TypeScript
530 lines
23 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import {
|
|
ShieldCheck,
|
|
ShieldAlert,
|
|
AlertTriangle,
|
|
Users,
|
|
Search,
|
|
Filter,
|
|
RefreshCw,
|
|
Eye,
|
|
CheckCircle2,
|
|
Coins,
|
|
Building2,
|
|
FileSpreadsheet,
|
|
Download,
|
|
AlertCircle,
|
|
} from 'lucide-react';
|
|
import {
|
|
getProjects,
|
|
getAllProgrammes,
|
|
getAllPortfolios,
|
|
getBudgets,
|
|
getProjectComplianceItems,
|
|
getProjectBeneficiaries,
|
|
getBeneficiaryOutcomes,
|
|
getProjectRisks,
|
|
} from '../services/api';
|
|
import {
|
|
Project,
|
|
Programme,
|
|
Portfolio,
|
|
ComplianceItem,
|
|
Beneficiary,
|
|
Outcome,
|
|
ComplianceStatus,
|
|
ApprovalStatus,
|
|
} from '../types';
|
|
import {
|
|
formatDate,
|
|
formatZAR,
|
|
formatCompactZAR,
|
|
getIndustrySectorLabel,
|
|
getProjectTypeLabel,
|
|
getApprovalStatusBadge,
|
|
} from '../lib/formatters';
|
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
|
import { ComplianceStatusBadge } from '../components/ui/StatusBadge';
|
|
import { ProjectComplianceModal } from '../components/projects/ProjectComplianceModal';
|
|
import { ExportButton } from '../components/ui/ExportButton';
|
|
import { ExportColumn } from '../utils/exportUtils';
|
|
|
|
export const CompliancePage: React.FC = () => {
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [selectedProgramme, setSelectedProgramme] = useState('All');
|
|
const [selectedComplianceFilter, setSelectedComplianceFilter] = useState<'All' | 'Compliant' | 'Noncompliant'>('All');
|
|
const [selectedProjectForAudit, setSelectedProjectForAudit] = useState<Project | null>(null);
|
|
const [isAuditModalOpen, setIsAuditModalOpen] = useState(false);
|
|
|
|
// Queries
|
|
const { data: projects = [], isLoading: loadingProjects, refetch: refetchProjects, isFetching } = useQuery({
|
|
queryKey: ['projects'],
|
|
queryFn: () => getProjects(),
|
|
});
|
|
|
|
const { data: programmes = [] } = useQuery({
|
|
queryKey: ['programmes'],
|
|
queryFn: () => getAllProgrammes(),
|
|
});
|
|
|
|
const { data: portfolios = [] } = useQuery({
|
|
queryKey: ['portfolios'],
|
|
queryFn: () => getAllPortfolios(),
|
|
});
|
|
|
|
const { data: allBudgets = [] } = useQuery({
|
|
queryKey: ['budgets'],
|
|
queryFn: () => getBudgets(1, 200),
|
|
});
|
|
|
|
const { data: beneficiariesMap = new Map<string | number, Beneficiary[]>() } = useQuery({
|
|
queryKey: ['allProjectBeneficiaries', projects.map((p) => p.id)],
|
|
queryFn: async () => {
|
|
const map = new Map<string | number, Beneficiary[]>();
|
|
await Promise.all(
|
|
projects.map(async (p) => {
|
|
try {
|
|
const bens = await getProjectBeneficiaries(p.id);
|
|
map.set(p.id, bens);
|
|
} catch {
|
|
map.set(p.id, []);
|
|
}
|
|
})
|
|
);
|
|
return map;
|
|
},
|
|
enabled: projects.length > 0,
|
|
});
|
|
|
|
// Query compliance items for all projects
|
|
const { data: complianceMap = new Map<string | number, ComplianceItem[]>(), isLoading: loadingComps } = useQuery({
|
|
queryKey: ['allProjectCompliance', projects.map((p) => p.id)],
|
|
queryFn: async () => {
|
|
const map = new Map<string | number, ComplianceItem[]>();
|
|
await Promise.all(
|
|
projects.map(async (p) => {
|
|
try {
|
|
const items = await getProjectComplianceItems(p.id);
|
|
map.set(p.id, items);
|
|
} catch {
|
|
map.set(p.id, []);
|
|
}
|
|
})
|
|
);
|
|
return map;
|
|
},
|
|
enabled: projects.length > 0,
|
|
});
|
|
|
|
// Query beneficiary outcomes
|
|
const allBensList = useMemo(() => {
|
|
const list: Beneficiary[] = [];
|
|
beneficiariesMap.forEach((bList) => list.push(...bList));
|
|
return list;
|
|
}, [beneficiariesMap]);
|
|
|
|
const { data: outcomesMap = new Map<string | number, Outcome[]>() } = useQuery({
|
|
queryKey: ['allBeneficiaryOutcomes', allBensList.map((b) => b.id)],
|
|
queryFn: async () => {
|
|
const map = new Map<string | number, Outcome[]>();
|
|
await Promise.all(
|
|
allBensList.map(async (b) => {
|
|
try {
|
|
const outs = await getBeneficiaryOutcomes(b.id);
|
|
map.set(b.id, outs);
|
|
} catch {
|
|
map.set(b.id, []);
|
|
}
|
|
})
|
|
);
|
|
return map;
|
|
},
|
|
enabled: allBensList.length > 0,
|
|
});
|
|
|
|
// Lookups
|
|
const programmeMap = useMemo(() => {
|
|
const map = new Map<string | number, Programme>();
|
|
programmes.forEach((pr) => map.set(pr.id, pr));
|
|
return map;
|
|
}, [programmes]);
|
|
|
|
// Aggregate project compliance records
|
|
const projectComplianceStats = useMemo(() => {
|
|
return projects.map((p) => {
|
|
const prog = programmeMap.get(p.programmeId);
|
|
const items = complianceMap.get(p.id) || [];
|
|
const projectBens = beneficiariesMap.get(p.id) || [];
|
|
|
|
let totalOutcomes = 0;
|
|
let metOutcomes = 0;
|
|
projectBens.forEach((b) => {
|
|
const bOutcomes = outcomesMap.get(b.id) || [];
|
|
totalOutcomes += bOutcomes.length;
|
|
metOutcomes += bOutcomes.filter(
|
|
(o) => Number(o.status) === ComplianceStatus.Met || Number(o.status) === ComplianceStatus.Exceeded
|
|
).length;
|
|
});
|
|
|
|
const totalItems = items.length;
|
|
const metItems = items.filter(
|
|
(i) => Number(i.status) === ComplianceStatus.Met || Number(i.status) === ComplianceStatus.Exceeded
|
|
).length;
|
|
const unmetItems = totalItems - metItems;
|
|
const unmetOutcomes = totalOutcomes - metOutcomes;
|
|
|
|
const isCompliant = unmetItems === 0 && unmetOutcomes === 0;
|
|
|
|
const projectBudgets = allBudgets.filter((b) => String(b.projectId) === String(p.id));
|
|
const approvedBudget = projectBudgets
|
|
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
|
|
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
|
|
|
const budgetAtRisk = isCompliant ? 0 : approvedBudget;
|
|
|
|
return {
|
|
project: p,
|
|
programme: prog,
|
|
totalItems,
|
|
metItems,
|
|
unmetItems,
|
|
totalOutcomes,
|
|
metOutcomes,
|
|
unmetOutcomes,
|
|
beneficiariesCount: projectBens.length,
|
|
isCompliant,
|
|
approvedBudget,
|
|
budgetAtRisk,
|
|
};
|
|
});
|
|
}, [projects, programmeMap, complianceMap, beneficiariesMap, outcomesMap, allBudgets]);
|
|
|
|
// Filtered rows
|
|
const filteredRows = useMemo(() => {
|
|
return projectComplianceStats.filter((row) => {
|
|
if (selectedProgramme !== 'All' && String(row.project.programmeId) !== selectedProgramme) {
|
|
return false;
|
|
}
|
|
if (selectedComplianceFilter === 'Compliant' && !row.isCompliant) {
|
|
return false;
|
|
}
|
|
if (selectedComplianceFilter === 'Noncompliant' && row.isCompliant) {
|
|
return false;
|
|
}
|
|
if (searchTerm.trim()) {
|
|
const q = searchTerm.toLowerCase();
|
|
const pName = (row.project.name || '').toLowerCase();
|
|
const prName = (row.programme?.name || '').toLowerCase();
|
|
const idStr = String(row.project.id);
|
|
if (!pName.includes(q) && !prName.includes(q) && !idStr.includes(q)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
}, [projectComplianceStats, selectedProgramme, selectedComplianceFilter, searchTerm]);
|
|
|
|
// Aggregate KPI figures
|
|
const totalProjectsCount = projectComplianceStats.length;
|
|
const compliantProjectsCount = projectComplianceStats.filter((r) => r.isCompliant).length;
|
|
const noncompliantProjectsCount = totalProjectsCount - compliantProjectsCount;
|
|
const overallComplianceRate = totalProjectsCount > 0 ? Math.round((compliantProjectsCount / totalProjectsCount) * 100) : 100;
|
|
const totalBudgetAtRisk = projectComplianceStats.reduce((sum, r) => sum + r.budgetAtRisk, 0);
|
|
const totalBeneficiaries = allBensList.length;
|
|
|
|
const complianceExportColumns: ExportColumn<any>[] = [
|
|
{ header: 'Project ID', accessor: (r) => r.project.id },
|
|
{ header: 'Project Name', accessor: (r) => r.project.name },
|
|
{ header: 'Programme', accessor: (r) => r.programme?.name || `Prog #${r.project.programmeId}` },
|
|
{ header: 'Sector', accessor: (r) => getIndustrySectorLabel(r.project.sector) },
|
|
{ header: 'Compliance Status', accessor: (r) => (r.isCompliant ? '100% Compliant' : 'Noncompliant') },
|
|
{ header: 'Requirements Met', accessor: (r) => `${r.metItems}/${r.totalItems}` },
|
|
{ header: 'Outcomes Met', accessor: (r) => `${r.metOutcomes}/${r.totalOutcomes}` },
|
|
{ header: 'Approved Budget', accessor: (r) => formatZAR(r.approvedBudget) },
|
|
{ header: 'Budget At Risk', accessor: (r) => formatZAR(r.budgetAtRisk) },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header Banner */}
|
|
<div className="bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 rounded-3xl border border-slate-800 shadow-xl relative overflow-hidden flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div className="space-y-1 relative z-10">
|
|
<div className="flex items-center gap-2">
|
|
<div className="p-2 bg-blue-500/10 border border-blue-500/20 rounded-xl text-blue-400">
|
|
<ShieldCheck className="w-5 h-5" />
|
|
</div>
|
|
<span className="text-xs font-bold uppercase tracking-wider text-blue-400">
|
|
Statutory Governance & Compliance
|
|
</span>
|
|
</div>
|
|
<h1 className="text-2xl sm:text-3xl font-extrabold text-white tracking-tight">
|
|
Portfolio Compliance Dashboard
|
|
</h1>
|
|
<p className="text-xs sm:text-sm text-slate-400 max-w-2xl">
|
|
Enterprise-wide compliance audit across all funded projects, beneficiary impact outcomes, and capital at risk.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2.5 relative z-10 self-start md:self-center">
|
|
<ExportButton
|
|
claim="compliance"
|
|
data={filteredRows}
|
|
columns={complianceExportColumns}
|
|
filename={`portfolio_compliance_${new Date().toISOString().split('T')[0]}`}
|
|
title="Portfolio Compliance Register"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => refetchProjects()}
|
|
disabled={isFetching}
|
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer shadow-md shadow-blue-600/30 disabled:opacity-50"
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin' : ''}`} />
|
|
<span>Refresh</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 5 Headline KPI Cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
|
{/* Card 1: Overall Rate */}
|
|
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Compliance Rate</span>
|
|
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
|
<ShieldCheck className="w-4 h-4" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-3">
|
|
<p className="text-2xl font-bold text-white tracking-tight">{overallComplianceRate}%</p>
|
|
<p className="text-[10px] text-emerald-400 font-mono mt-1">100% threshold target</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card 2: Compliant Projects */}
|
|
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Compliant</span>
|
|
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
|
<CheckCircle2 className="w-4 h-4" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-3">
|
|
<p className="text-2xl font-bold text-emerald-400 tracking-tight">{compliantProjectsCount}</p>
|
|
<p className="text-[10px] text-slate-400 font-mono mt-1">Eligible for approval</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card 3: Noncompliant Projects */}
|
|
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Noncompliant</span>
|
|
<div className="p-1.5 rounded-lg bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
|
<ShieldAlert className="w-4 h-4" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-3">
|
|
<p className="text-2xl font-bold text-rose-400 tracking-tight">{noncompliantProjectsCount}</p>
|
|
<p className="text-[10px] text-rose-400 font-mono mt-1">Approvals blocked</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card 4: Total Capital At Risk */}
|
|
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Capital At Risk</span>
|
|
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
|
<AlertTriangle className="w-4 h-4" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-3">
|
|
<p className="text-2xl font-bold text-amber-400 tracking-tight">{formatCompactZAR(totalBudgetAtRisk)}</p>
|
|
<p className="text-[10px] text-slate-400 font-mono mt-1">{formatZAR(totalBudgetAtRisk)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card 5: Beneficiaries Monitored */}
|
|
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-md backdrop-blur-sm flex flex-col justify-between">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Beneficiaries</span>
|
|
<div className="p-1.5 rounded-lg bg-purple-500/10 text-purple-400 border border-purple-500/20">
|
|
<Users className="w-4 h-4" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-3">
|
|
<p className="text-2xl font-bold text-white tracking-tight">{totalBeneficiaries}</p>
|
|
<p className="text-[10px] text-slate-400 font-mono mt-1">Impact groups monitored</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter Toolbar */}
|
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-lg flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<div className="relative">
|
|
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search project, programme, ID..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-9 pr-3 py-1.5 bg-[#090d16] border border-slate-700/80 rounded-xl text-xs text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 w-64 transition"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-xs">
|
|
<span className="text-slate-400 mr-2 text-[11px] font-semibold uppercase">Programme:</span>
|
|
<select
|
|
value={selectedProgramme}
|
|
onChange={(e) => setSelectedProgramme(e.target.value)}
|
|
className="bg-transparent text-white focus:outline-none cursor-pointer max-w-[180px] truncate"
|
|
>
|
|
<option value="All" className="bg-slate-900">All Programmes</option>
|
|
{programmes.map((pr) => (
|
|
<option key={pr.id} value={pr.id} className="bg-slate-900">
|
|
{pr.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-xs">
|
|
<span className="text-slate-400 mr-2 text-[11px] font-semibold uppercase">Compliance:</span>
|
|
<select
|
|
value={selectedComplianceFilter}
|
|
onChange={(e) => setSelectedComplianceFilter(e.target.value as any)}
|
|
className="bg-transparent text-white focus:outline-none cursor-pointer"
|
|
>
|
|
<option value="All" className="bg-slate-900">All Statuses</option>
|
|
<option value="Compliant" className="bg-slate-900">Compliant (100%)</option>
|
|
<option value="Noncompliant" className="bg-slate-900">Noncompliant (Blocked)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<span className="text-xs text-slate-400 font-medium">
|
|
Showing <strong className="text-white">{filteredRows.length}</strong> of {projectComplianceStats.length} projects
|
|
</span>
|
|
</div>
|
|
|
|
{/* Projects Compliance Register Table */}
|
|
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 overflow-hidden shadow-xl backdrop-blur-sm">
|
|
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between bg-[#0d121f]/90">
|
|
<div className="flex items-center gap-2">
|
|
<ShieldCheck className="w-4 h-4 text-cyan-400" />
|
|
<h3 className="text-xs font-bold uppercase tracking-wider text-white">
|
|
Project Compliance Matrix & Audit Register
|
|
</h3>
|
|
</div>
|
|
<span className="text-[11px] text-slate-400">
|
|
Invariant: 100% compliance required for formal project approval
|
|
</span>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-xs border-collapse">
|
|
<thead>
|
|
<tr className="bg-[#090d16] border-b border-slate-800 text-slate-400 text-[11px] font-semibold select-none">
|
|
<th className="py-3.5 px-5">Project Name & ID</th>
|
|
<th className="py-3.5 px-4">Programme</th>
|
|
<th className="py-3.5 px-4">Compliance Status</th>
|
|
<th className="py-3.5 px-4 text-center">Requirements Met</th>
|
|
<th className="py-3.5 px-4 text-center">Outcomes Met</th>
|
|
<th className="py-3.5 px-4 text-right">Approved Budget</th>
|
|
<th className="py-3.5 px-4 text-right">Capital At Risk</th>
|
|
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-800/60 font-medium">
|
|
{loadingProjects || loadingComps ? (
|
|
<tr>
|
|
<td colSpan={8} className="py-12 text-center text-slate-400 text-xs">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
|
<span>Loading compliance matrix...</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : filteredRows.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={8} className="py-10 text-center text-slate-500 text-xs">
|
|
No projects matching active compliance filters.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredRows.map((r) => (
|
|
<tr key={r.project.id} className="hover:bg-slate-800/40 transition-colors">
|
|
<td className="py-3.5 px-5">
|
|
<span className="font-semibold text-white block">{r.project.name}</span>
|
|
<span className="text-[10px] text-slate-500 font-mono">ID #{r.project.id} • {getIndustrySectorLabel(r.project.sector)}</span>
|
|
</td>
|
|
<td className="py-3.5 px-4 text-slate-300">
|
|
{r.programme?.name || `Prog #${r.project.programmeId}`}
|
|
</td>
|
|
<td className="py-3.5 px-4">
|
|
{r.isCompliant ? (
|
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/30">
|
|
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
|
|
<span>100% Compliant</span>
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/30">
|
|
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
|
<span>Noncompliant ({r.unmetItems + r.unmetOutcomes} Unmet)</span>
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3.5 px-4 text-center font-mono font-semibold">
|
|
<span className={r.unmetItems === 0 ? 'text-emerald-400' : 'text-rose-400'}>
|
|
{r.metItems}/{r.totalItems}
|
|
</span>
|
|
</td>
|
|
<td className="py-3.5 px-4 text-center font-mono font-semibold">
|
|
<span className={r.unmetOutcomes === 0 ? 'text-emerald-400' : 'text-purple-400'}>
|
|
{r.metOutcomes}/{r.totalOutcomes}
|
|
</span>
|
|
</td>
|
|
<td className="py-3.5 px-4 text-right font-mono text-slate-300">
|
|
{formatZAR(r.approvedBudget)}
|
|
</td>
|
|
<td className="py-3.5 px-4 text-right font-mono font-bold">
|
|
<span className={r.budgetAtRisk > 0 ? 'text-rose-400' : 'text-emerald-400'}>
|
|
{formatZAR(r.budgetAtRisk)}
|
|
</span>
|
|
</td>
|
|
<td className="py-3.5 px-5 text-right whitespace-nowrap">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedProjectForAudit(r.project);
|
|
setIsAuditModalOpen(true);
|
|
}}
|
|
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-950/40 hover:bg-emerald-900/60 border border-emerald-500/30 px-3 py-1.5 rounded-xl transition cursor-pointer shadow-xs active:scale-95"
|
|
>
|
|
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
|
<span>Inspect Compliance</span>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Compliance Audit Modal */}
|
|
<ProjectComplianceModal
|
|
isOpen={isAuditModalOpen}
|
|
onClose={() => {
|
|
setIsAuditModalOpen(false);
|
|
setSelectedProjectForAudit(null);
|
|
}}
|
|
project={selectedProjectForAudit}
|
|
programme={selectedProjectForAudit ? programmeMap.get(selectedProjectForAudit.programmeId) || null : null}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|