Improved compliance monitoring
This commit is contained in:
@@ -42,9 +42,9 @@ import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import { ApprovalStatusBadge } from '../components/ui/StatusBadge';
|
||||
import { Beneficiary, ComplianceItem, Outcome, ApprovalStatus } from '../types';
|
||||
import { getApprovalStatusBadge, formatDate, APPROVAL_STATUS_OPTIONS } from '../lib/formatters';
|
||||
import { ApprovalStatusBadge, ComplianceStatusBadge } from '../components/ui/StatusBadge';
|
||||
import { Beneficiary, ComplianceItem, Outcome, ApprovalStatus, ComplianceStatus } from '../types';
|
||||
import { getApprovalStatusBadge, getComplianceStatusBadge, formatDate, APPROVAL_STATUS_OPTIONS, COMPLIANCE_STATUS_OPTIONS } from '../lib/formatters';
|
||||
|
||||
const beneficiarySchema = z.object({
|
||||
name: z.string().min(2, 'Beneficiary name required'),
|
||||
@@ -58,11 +58,13 @@ const complianceSchema = z.object({
|
||||
title: z.string().min(3, 'Compliance title required'),
|
||||
requirements: z.string().min(5, 'Requirements detail required'),
|
||||
status: z.coerce.number().optional(),
|
||||
beneficiaryId: z.string().optional(),
|
||||
});
|
||||
|
||||
const outcomeSchema = z.object({
|
||||
name: z.string().min(3, 'Outcome name required'),
|
||||
description: z.string().min(5, 'Description required'),
|
||||
status: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
type BeneficiaryFormValues = z.infer<typeof beneficiarySchema>;
|
||||
@@ -224,7 +226,13 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
mutationFn: (vals: ComplianceFormValues) => {
|
||||
const targetProjId = compTargetProjectId || (selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id);
|
||||
if (!targetProjId) throw new Error('No project selected');
|
||||
return createComplianceItem({ projectId: targetProjId, title: vals.title, requirements: vals.requirements });
|
||||
return createComplianceItem({
|
||||
projectId: targetProjId,
|
||||
title: vals.title,
|
||||
requirements: vals.requirements,
|
||||
status: vals.status ?? ComplianceStatus.NotMet,
|
||||
beneficiaryId: vals.beneficiaryId && vals.beneficiaryId !== 'none' ? Number(vals.beneficiaryId) : null,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['complianceItems'] });
|
||||
@@ -242,7 +250,8 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
complianceItemId: editingComp.id,
|
||||
title: vals.title,
|
||||
requirements: vals.requirements,
|
||||
status: vals.status ?? ApprovalStatus.Pending,
|
||||
status: vals.status ?? ComplianceStatus.NotMet,
|
||||
beneficiaryId: vals.beneficiaryId && vals.beneficiaryId !== 'none' ? Number(vals.beneficiaryId) : null,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -258,7 +267,12 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
const createOutcomeMutation = useMutation({
|
||||
mutationFn: (vals: OutcomeFormValues) => {
|
||||
if (!selectedBenForDetail) throw new Error('No beneficiary selected');
|
||||
return createOutcome({ beneficiaryId: selectedBenForDetail.id, name: vals.name, description: vals.description });
|
||||
return createOutcome({
|
||||
beneficiaryId: selectedBenForDetail.id,
|
||||
name: vals.name,
|
||||
description: vals.description,
|
||||
status: vals.status ?? ComplianceStatus.NotMet,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetchOutcomes();
|
||||
@@ -360,7 +374,8 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
compForm.reset({
|
||||
title: fresh.title || '',
|
||||
requirements: fresh.requirements || '',
|
||||
status: fresh.status ?? ApprovalStatus.Pending,
|
||||
status: fresh.status ?? ComplianceStatus.NotMet,
|
||||
beneficiaryId: fresh.beneficiaryId ? String(fresh.beneficiaryId) : 'none',
|
||||
});
|
||||
setIsCompModalOpen(true);
|
||||
} catch {
|
||||
@@ -368,7 +383,8 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
compForm.reset({
|
||||
title: c.title || '',
|
||||
requirements: c.requirements || '',
|
||||
status: c.status ?? ApprovalStatus.Pending,
|
||||
status: c.status ?? ComplianceStatus.NotMet,
|
||||
beneficiaryId: c.beneficiaryId ? String(c.beneficiaryId) : 'none',
|
||||
});
|
||||
setIsCompModalOpen(true);
|
||||
}
|
||||
@@ -868,7 +884,7 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
{c.requirements || 'None'}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 whitespace-nowrap">
|
||||
<ApprovalStatusBadge status={c.status} />
|
||||
<ComplianceStatusBadge status={c.status} />
|
||||
</td>
|
||||
<td className="py-3.5 px-4 whitespace-nowrap text-slate-400">{formatDate(c.createdAt)}</td>
|
||||
<td className="py-3.5 px-5 text-right whitespace-nowrap">
|
||||
@@ -1018,45 +1034,36 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editingComp && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Verification Status</label>
|
||||
{Number(editingComp.status) === ApprovalStatus.Approved ? (
|
||||
<div
|
||||
className="w-full bg-slate-900 border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
|
||||
title="Approved compliance records are immutable."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Approved (Immutable - Cannot Be Changed)</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
{...compForm.register('status')}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{(() => {
|
||||
const canApproveComp = canApproveRecord('beneficiaries', editingComp?.createdBy);
|
||||
const isSelf = isMaker(editingComp?.createdBy);
|
||||
return APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||
const isOptDisabled = isApprovedOpt && !canApproveComp;
|
||||
let reason = '';
|
||||
if (isApprovedOpt && !canApprove('beneficiaries')) {
|
||||
reason = ' (Requires Update Permission)';
|
||||
} else if (isApprovedOpt && isSelf) {
|
||||
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||
}
|
||||
return (
|
||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||
{opt.label}{reason}
|
||||
</option>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Linked Beneficiary (Optional)</label>
|
||||
<select
|
||||
{...compForm.register('beneficiaryId')}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
|
||||
>
|
||||
<option value="none">-- Project-Wide (All Beneficiaries) --</option>
|
||||
{beneficiaries
|
||||
.filter((b) => !compTargetProjectId || String(b.projectId) === String(compTargetProjectId))
|
||||
.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name} ({b.category})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Compliance Status</label>
|
||||
<select
|
||||
{...compForm.register('status')}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
|
||||
>
|
||||
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
|
||||
<button
|
||||
@@ -1194,6 +1201,20 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Fulfillment Status</label>
|
||||
<select
|
||||
{...outcomeForm.register('status')}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
|
||||
>
|
||||
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ShieldAlert,
|
||||
RotateCcw,
|
||||
LayoutDashboard,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getDashboard,
|
||||
@@ -26,9 +27,10 @@ import {
|
||||
getDisbursements,
|
||||
getAllRisks,
|
||||
getAllIndicators,
|
||||
getProjectComplianceItems,
|
||||
} from '../services/api';
|
||||
import { formatCompactZAR, formatZAR, getIndustrySectorLabel } from '../lib/formatters';
|
||||
import { ApprovalStatus } from '../types';
|
||||
import { ApprovalStatus, ComplianceStatus } from '../types';
|
||||
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
@@ -39,6 +41,10 @@ interface AttentionProject {
|
||||
programme: string;
|
||||
status: string;
|
||||
risk: string;
|
||||
complianceStatus: string;
|
||||
isCompliant: boolean;
|
||||
unmetCount: number;
|
||||
budgetAtRisk: number;
|
||||
financialVariance: string;
|
||||
varianceType: string;
|
||||
maxScore: number;
|
||||
@@ -108,6 +114,25 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
queryFn: () => getAllIndicators(),
|
||||
});
|
||||
|
||||
const { data: projectComplianceMap = new Map<string | number, any[]>() } = useQuery({
|
||||
queryKey: ['projectComplianceMap', filteredProjects.map((p) => p.id)],
|
||||
queryFn: async () => {
|
||||
const map = new Map<string | number, any[]>();
|
||||
await Promise.all(
|
||||
filteredProjects.map(async (p) => {
|
||||
try {
|
||||
const items = await getProjectComplianceItems(p.id);
|
||||
map.set(p.id, items);
|
||||
} catch {
|
||||
map.set(p.id, []);
|
||||
}
|
||||
})
|
||||
);
|
||||
return map;
|
||||
},
|
||||
enabled: filteredProjects.length > 0,
|
||||
});
|
||||
|
||||
const refetchAll = () => {
|
||||
refetchDashboard();
|
||||
queryClient.invalidateQueries();
|
||||
@@ -217,6 +242,34 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
return dashboard?.atRiskCount ?? 0;
|
||||
}, [dashboard, filteredRisks]);
|
||||
|
||||
// Compliance and Capital at Risk calculations
|
||||
const totalBudgetAtRisk = useMemo(() => {
|
||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||
return targetProjects.reduce((sum, p) => {
|
||||
const pComps = projectComplianceMap.get(p.id) || [];
|
||||
const hasUnmet = pComps.some((c) => Number(c.status) === ComplianceStatus.NotMet || Number(c.status) === 1);
|
||||
const isPendingOrRejected = p.status === ApprovalStatus.Rejected || p.status === ApprovalStatus.Pending;
|
||||
if (hasUnmet || isPendingOrRejected) {
|
||||
const pBudgets = budgets.filter((b) => String(b.projectId) === String(p.id));
|
||||
const pApproved = pBudgets
|
||||
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
|
||||
.reduce((bSum, b) => bSum + (Number(b.amount) || 0), 0);
|
||||
return sum + pApproved;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
}, [filteredProjects, projects, projectComplianceMap, budgets]);
|
||||
|
||||
const complianceRate = useMemo(() => {
|
||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||
if (targetProjects.length === 0) return 100;
|
||||
const compliantCount = targetProjects.filter((p) => {
|
||||
const pComps = projectComplianceMap.get(p.id) || [];
|
||||
return !pComps.some((c) => Number(c.status) === ComplianceStatus.NotMet || Number(c.status) === 1);
|
||||
}).length;
|
||||
return Math.round((compliantCount / targetProjects.length) * 100);
|
||||
}, [filteredProjects, projects, projectComplianceMap]);
|
||||
|
||||
const beneficiariesReached = useMemo(() => {
|
||||
if (dashboard?.beneficiariesReached && dashboard.beneficiariesReached > 0) {
|
||||
return dashboard.beneficiariesReached;
|
||||
@@ -325,21 +378,38 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
varianceType = 'negative';
|
||||
}
|
||||
|
||||
const pComps = projectComplianceMap.get(p.id) || [];
|
||||
const unmetCount = pComps.filter(
|
||||
(c) => Number(c.status) === ComplianceStatus.NotMet || Number(c.status) === 1
|
||||
).length;
|
||||
const isCompliant = unmetCount === 0;
|
||||
|
||||
const pBudgets = budgets.filter((b) => String(b.projectId) === String(p.id));
|
||||
const pApprovedBudget = pBudgets
|
||||
.filter((b) => Number(b.status) === ApprovalStatus.Approved || b.status === undefined)
|
||||
.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
|
||||
const projectBudgetAtRisk = !isCompliant || riskLevel === 'RED' || riskLevel === 'AMBER' ? pApprovedBudget : 0;
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
programme: p.programmeName || `Programme ${p.programmeId}`,
|
||||
status: statusLabel,
|
||||
risk: riskLevel,
|
||||
complianceStatus: isCompliant ? 'Compliant' : `Noncompliant (${unmetCount} Unmet)`,
|
||||
isCompliant,
|
||||
unmetCount,
|
||||
budgetAtRisk: projectBudgetAtRisk,
|
||||
financialVariance: variance,
|
||||
varianceType,
|
||||
maxScore,
|
||||
};
|
||||
})
|
||||
.filter((p) => p.risk === 'RED' || p.risk === 'AMBER')
|
||||
.sort((a, b) => b.maxScore - a.maxScore)
|
||||
.slice(0, 5);
|
||||
}, [projects, filteredProjects, risks]);
|
||||
.filter((p) => p.risk === 'RED' || p.risk === 'AMBER' || !p.isCompliant)
|
||||
.sort((a, b) => b.budgetAtRisk - a.budgetAtRisk || b.maxScore - a.maxScore)
|
||||
.slice(0, 8);
|
||||
}, [projects, filteredProjects, risks, projectComplianceMap, budgets]);
|
||||
|
||||
const {
|
||||
sortedData: sortedAttentionProjects,
|
||||
@@ -498,8 +568,8 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6 Headline Portfolio KPI Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{/* 8 Headline Portfolio KPI Cards with Compliance & Budget At Risk */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 xl:grid-cols-8 gap-3.5">
|
||||
{/* Card 1: Portfolio Value */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
@@ -637,6 +707,50 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">Directly impacted</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 7: Budget At Risk */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-rose-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Budget At Risk</span>
|
||||
<HelpTooltip
|
||||
title="Total Budgeted Capital At Risk"
|
||||
content="Aggregated approved budget across all projects currently flagged as noncompliant or carrying unmitigated high risks."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-rose-500/10 text-rose-400 border border-rose-500/20 group-hover:scale-110 transition">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-rose-400 tracking-tight">{formatCompactZAR(totalBudgetAtRisk)}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">{formatZAR(totalBudgetAtRisk)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 8: Compliance Health */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Compliance</span>
|
||||
<HelpTooltip
|
||||
title="Portfolio Compliance Health"
|
||||
content="Percentage of active projects meeting 100% statutory compliance and beneficiary outcome criteria."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-110 transition">
|
||||
<ShieldCheck className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-emerald-400 tracking-tight">{complianceRate}%</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">100% statutory gate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual Models Section (Sector Breakdown & Disbursements) */}
|
||||
@@ -774,7 +888,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
<div className="p-5 border-b border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 bg-[#0d121f]/90">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-white">Projects Requiring Attention</h3>
|
||||
<h3 className="text-base font-semibold text-white">Projects Flagged At Risk & Noncompliant</h3>
|
||||
<HelpTooltip
|
||||
title="Exception Escalations"
|
||||
content="Identifies projects breaching risk thresholds, schedule deadlines, or financial variances (>10%) requiring executive steering committee action."
|
||||
@@ -807,11 +921,14 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
<SortableTh column="status" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} className="py-3.5 px-4">
|
||||
Status
|
||||
</SortableTh>
|
||||
<SortableTh column="complianceStatus" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} className="py-3.5 px-4">
|
||||
Compliance
|
||||
</SortableTh>
|
||||
<SortableTh column="risk" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} align="center" className="py-3.5 px-4 text-center">
|
||||
Risk
|
||||
</SortableTh>
|
||||
<SortableTh column="financialVariance" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} align="right" className="py-3.5 px-4 text-right">
|
||||
Financial Variance
|
||||
<SortableTh column="budgetAtRisk" currentColumn={projectSortCol} direction={projectSortDir} onSort={handleProjectSort} align="right" className="py-3.5 px-4 text-right">
|
||||
Budget At Risk
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-5 text-right font-medium">Action</th>
|
||||
</tr>
|
||||
@@ -819,7 +936,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{sortedAttentionProjects.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-xs text-slate-500">
|
||||
<td colSpan={7} className="py-8 text-center text-xs text-slate-500">
|
||||
No active projects currently flagged for executive attention. All projects are operating within target thresholds.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -844,6 +961,19 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
{p.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
{p.isCompliant ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/30">
|
||||
<CheckCircle className="w-3 h-3 text-emerald-400" />
|
||||
<span>Compliant</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 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 ({p.unmetCount} Unmet)</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-center">
|
||||
<span
|
||||
className={`inline-flex items-center justify-center font-bold px-2.5 py-0.5 rounded-md text-[10px] shadow-xs ${
|
||||
@@ -857,13 +987,9 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
{p.risk}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right">
|
||||
<span
|
||||
className={`font-mono font-bold text-xs ${
|
||||
p.varianceType === 'negative' ? 'text-rose-400' : 'text-emerald-400'
|
||||
}`}
|
||||
>
|
||||
{p.financialVariance}
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-xs">
|
||||
<span className={p.budgetAtRisk > 0 ? 'text-rose-400' : 'text-emerald-400'}>
|
||||
{formatZAR(p.budgetAtRisk)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right">
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Filter,
|
||||
FileText,
|
||||
Activity,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getProjects,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
createIndicator,
|
||||
recordIndicatorActual,
|
||||
getBudgets,
|
||||
getActivities,
|
||||
} from '../services/api';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
@@ -73,6 +75,7 @@ import {
|
||||
getUserEmail,
|
||||
} from '../lib/formatters';
|
||||
import { ProjectViewModal } from '../components/projects/ProjectViewModal';
|
||||
import { ProjectComplianceModal } from '../components/projects/ProjectComplianceModal';
|
||||
import { ProjectFormModal } from '../components/projects/ProjectFormModal';
|
||||
import { ProjectMilestonesModal } from '../components/projects/ProjectMilestonesModal';
|
||||
import { ProjectActivitiesModal } from '../components/projects/ProjectActivitiesModal';
|
||||
@@ -192,6 +195,8 @@ export const ProjectsPage: React.FC = () => {
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
|
||||
const [activeModalProject, setActiveModalProject] = useState<Project | null>(null);
|
||||
const [selectedProjectForCompliance, setSelectedProjectForCompliance] = useState<Project | null>(null);
|
||||
const [isComplianceModalOpen, setIsComplianceModalOpen] = useState(false);
|
||||
const [isMilestonesModalOpen, setIsMilestonesModalOpen] = useState(false);
|
||||
const [isActivitiesModalOpen, setIsActivitiesModalOpen] = useState(false);
|
||||
const [isIndicatorsModalOpen, setIsIndicatorsModalOpen] = useState(false);
|
||||
@@ -237,6 +242,31 @@ export const ProjectsPage: React.FC = () => {
|
||||
queryFn: () => getBudgets(1, 100),
|
||||
});
|
||||
|
||||
const { data: allProjectActivities = new Map<string | number, any[]>() } = useQuery({
|
||||
queryKey: ['allProjectActivities', projects.map((p) => p.id)],
|
||||
queryFn: async () => {
|
||||
const map = new Map<string | number, any[]>();
|
||||
await Promise.all(
|
||||
projects.map(async (p) => {
|
||||
try {
|
||||
const acts = await getActivities(p.id);
|
||||
map.set(p.id, acts);
|
||||
} catch {
|
||||
map.set(p.id, []);
|
||||
}
|
||||
})
|
||||
);
|
||||
return map;
|
||||
},
|
||||
enabled: projects.length > 0,
|
||||
});
|
||||
|
||||
const handleOpenComplianceStatus = (p: Project, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSelectedProjectForCompliance(p);
|
||||
setIsComplianceModalOpen(true);
|
||||
};
|
||||
|
||||
// Programme lookup map
|
||||
const programmeMap = useMemo(() => {
|
||||
const map = new Map<string | number, Programme>();
|
||||
@@ -977,13 +1007,24 @@ export const ProjectsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-36">Actions</th>
|
||||
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-36">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<span>Project Variance</span>
|
||||
<HelpTooltip
|
||||
title="Project Variance"
|
||||
content="Approved allocated budget minus scheduled milestone activity values. Positive (green) indicates within budget constraints; negative (red) indicates cost overrun."
|
||||
size="sm"
|
||||
placement="bottom"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-56">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60 font-medium">
|
||||
{loadingProjects ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-12 text-center text-slate-400 text-xs">
|
||||
<td colSpan={7} className="p-12 text-center text-slate-400 text-xs">
|
||||
<div className="flex flex-col items-center justify-center space-y-2">
|
||||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span>Loading project directory...</span>
|
||||
@@ -992,7 +1033,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
</tr>
|
||||
) : sortedProjects.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-12 text-center text-slate-400">
|
||||
<td colSpan={7} className="p-12 text-center text-slate-400">
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<FolderKanban className="w-10 h-10 text-slate-600" />
|
||||
<p className="text-sm font-semibold text-slate-300">No matching projects found</p>
|
||||
@@ -1166,17 +1207,51 @@ export const ProjectsPage: React.FC = () => {
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Row Action: View Project (Opens Large WBS & Gantt Console) */}
|
||||
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleOpenProjectView(p, e)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl transition cursor-pointer shadow-sm shadow-blue-600/25 active:scale-95"
|
||||
title="Open Project View & Interactive Gantt Timeline"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>View Project</span>
|
||||
</button>
|
||||
{/* Project Variance Column */}
|
||||
{(() => {
|
||||
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
|
||||
const pActs = allProjectActivities.get(p.id) || [];
|
||||
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
const pVariance = approvedBudget - pActsTotal;
|
||||
const isPos = pVariance >= 0;
|
||||
return (
|
||||
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36 font-mono font-bold" onClick={(e) => e.stopPropagation()}>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs border ${
|
||||
isPos
|
||||
? '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: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`}
|
||||
>
|
||||
{isPos ? `+${formatZAR(pVariance)}` : `-${formatZAR(Math.abs(pVariance))}`}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Row Action: View Project & Compliance Status */}
|
||||
<td className="py-3.5 px-4 text-right whitespace-nowrap w-56" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleOpenProjectView(p, e)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl transition cursor-pointer shadow-sm shadow-blue-600/25 active:scale-95"
|
||||
title="Open Project View & Interactive Gantt Timeline"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>View Project</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleOpenComplianceStatus(p, e)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-emerald-300 bg-emerald-950/40 hover:bg-emerald-900/60 border border-emerald-500/30 rounded-xl transition cursor-pointer shadow-sm active:scale-95"
|
||||
title="View Compliance Status, Risks & Beneficiaries"
|
||||
>
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Compliance Status</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -1367,7 +1442,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{sortedIndicatorsData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-12 text-center text-slate-500">
|
||||
<td colSpan={7} className="py-12 text-center text-slate-500">
|
||||
<TrendingUp className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||
{indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorUnitFilter !== 'all'
|
||||
? 'No indicators match your filter criteria.'
|
||||
@@ -1389,10 +1464,11 @@ export const ProjectsPage: React.FC = () => {
|
||||
setSelectedIndicatorForActual(ind);
|
||||
setActualInput(ind.actualAmount);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-600 px-3 py-1.5 rounded-lg border border-blue-500/25 transition cursor-pointer"
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 px-3 py-1.5 rounded-lg border border-slate-700/60 transition cursor-pointer"
|
||||
title="Edit actual realized value in place"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span>Record Actual</span>
|
||||
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Edit Actual</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1657,6 +1733,16 @@ export const ProjectsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
{/* Project View Modal (Comprehensive Console, Navigable Gantt Timeline & WBS) */}
|
||||
<ProjectComplianceModal
|
||||
isOpen={isComplianceModalOpen}
|
||||
onClose={() => {
|
||||
setIsComplianceModalOpen(false);
|
||||
setSelectedProjectForCompliance(null);
|
||||
}}
|
||||
project={selectedProjectForCompliance}
|
||||
programme={selectedProjectForCompliance ? programmeMap.get(selectedProjectForCompliance.programmeId) || null : null}
|
||||
/>
|
||||
|
||||
<ProjectViewModal
|
||||
isOpen={isProjectViewModalOpen}
|
||||
onClose={() => {
|
||||
@@ -1886,7 +1972,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
<Modal
|
||||
isOpen={!!selectedIndicatorForActual}
|
||||
onClose={() => setSelectedIndicatorForActual(null)}
|
||||
title="Record Indicator Actual Value"
|
||||
title="Edit Indicator Actual Value"
|
||||
subtitle={selectedIndicatorForActual?.name || ''}
|
||||
helpText="Logs a certified field or audited measurement against the target, recalculating the achievement percentage and RAG rating (BR-009)."
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user