Improved compliance monitoring
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user