Added Portfolio Analytics refresh button and card-per-award with contract, programme, and portfolio details

This commit is contained in:
2026-10-26 17:45:52 +02:00
parent 2690b3230e
commit 80c798e5e6
@@ -30,6 +30,9 @@ import {
Menu,
Reply,
Mail,
Layers,
FolderKanban,
PieChart,
} from 'lucide-react';
import { useAuth } from '../../auth/AuthProvider';
import { useRbac } from '../../auth/rbac';
@@ -47,6 +50,8 @@ import {
getEvidenceSecureLink,
submitActivityForApproval,
resolveUserIdByEmail,
getAllProgrammes,
getPortfolios,
} from '../../services/api';
import {
ApplicantData,
@@ -58,6 +63,8 @@ import {
Contract,
Award,
Organisation,
Programme,
Portfolio,
ApprovalStatus,
ActivityStatus,
ContractStatus,
@@ -66,7 +73,14 @@ import {
NotificationStatus,
NotificationPlatform,
} from '../../types';
import { formatZAR, formatCompactZAR, getUserEmail } from '../../lib/formatters';
import {
formatZAR,
formatCompactZAR,
getUserEmail,
getApprovalStatusBadge,
getEvidenceTypeLabel,
} from '../../lib/formatters';
import { encodeHashIdSync } from '../../services/toolsService';
import { MessageViewer } from '../notifications/MessageViewer';
import { stripMarkdown } from '../../lib/markdownUtils';
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
@@ -74,6 +88,16 @@ import { ProjectCard, EnrichedPartnerProject } from './ProjectCard';
import { EvidenceUploadModal } from './EvidenceUploadModal';
import { PartnerMessageComposerModal } from '../notifications/PartnerMessageComposerModal';
export interface EnrichedAwardEntry {
award: Award;
contract: Contract | null;
programme: Programme | null;
portfolio: Portfolio | null;
fundingOrganisation: Organisation | null;
invitedOrganisation: Organisation | null;
linkedProject: Project | null;
}
type NavTab = 'dashboard' | 'messages' | 'contracts' | 'analytics' | 'documents';
type SortOption = 'date_added' | 'grant_amount' | 'days_left' | 'name';
@@ -160,6 +184,7 @@ export function DashboardPage() {
const [contractFile, setContractFile] = useState<File | null>(null);
const [signatureRef, setSignatureRef] = useState('');
const [isRefreshingAnalytics, setIsRefreshingAnalytics] = useState(false);
// Contact / Message form state
const [messageSubject, setMessageSubject] = useState('');
@@ -263,13 +288,148 @@ export function DashboardPage() {
};
}, [applicantDataList]);
// Fetch programmes to enrich award cards with Programme & Portfolio info (Requirement #2)
const { data: allProgrammes = [] } = useQuery<Programme[]>({
queryKey: ['all-programmes'],
queryFn: () => getAllProgrammes(1, 100),
staleTime: 5 * 60 * 1000,
});
// Fetch portfolios to enrich award cards with Portfolio info (Requirement #2)
const { data: allPortfolios = [] } = useQuery<Portfolio[]>({
queryKey: ['all-portfolios'],
queryFn: () => getPortfolios(),
staleTime: 5 * 60 * 1000,
});
const programmesMap = useMemo(() => {
const map = new Map<string, Programme>();
for (const p of allProgrammes) {
if (p && p.id !== undefined && p.id !== null) {
map.set(String(p.id), p);
}
}
return map;
}, [allProgrammes]);
const portfoliosMap = useMemo(() => {
const map = new Map<string, Portfolio>();
for (const pf of allPortfolios) {
if (pf && pf.id !== undefined && pf.id !== null) {
map.set(String(pf.id), pf);
}
}
return map;
}, [allPortfolios]);
// Construct EnrichedAwardEntry[]: Card per award, with contract, programme & portfolio info (Requirement #2)
const enrichedAwards: EnrichedAwardEntry[] = useMemo(() => {
const list: EnrichedAwardEntry[] = [];
const seenAwardIds = new Set<string>();
for (const record of applicantDataList) {
const recAwards = record.awards || [];
const recContract = record.contract || null;
const recFundingOrg = record.fundingOrganisation || partnerSummary.primaryFundingOrg;
const recInvitedOrg = record.invitedOrganisation || partnerSummary.primaryInvitedOrg;
const recProjects = record.projects || [];
for (const aw of recAwards) {
if (!aw || !aw.id) continue;
const awIdStr = String(aw.id);
if (seenAwardIds.has(awIdStr)) continue;
seenAwardIds.add(awIdStr);
// Find associated contract
let matchedContract: Contract | null = null;
if (recContract && String(recContract.awardId) === awIdStr) {
matchedContract = recContract;
} else {
for (const otherRec of applicantDataList) {
if (otherRec.contract && String(otherRec.contract.awardId) === awIdStr) {
matchedContract = otherRec.contract;
break;
}
}
}
if (!matchedContract && recContract && recAwards.length === 1) {
matchedContract = recContract;
}
// Linked project
const linkedProject =
recProjects.find(p => String(p.id) === String(aw.projectId)) ||
recProjects.find(p => String(p.programmeId) === String(aw.programmeId)) ||
null;
// Programme lookup
const prog =
(aw.programmeId ? programmesMap.get(String(aw.programmeId)) : null) ||
(linkedProject?.programmeId ? programmesMap.get(String(linkedProject.programmeId)) : null) ||
null;
// Portfolio lookup
const port =
(prog?.portfolioId ? portfoliosMap.get(String(prog.portfolioId)) : null) ||
(aw.tenantId ? portfoliosMap.get(String(aw.tenantId)) : null) ||
(allPortfolios.length > 0 ? allPortfolios[0] : null) ||
null;
list.push({
award: aw,
contract: matchedContract,
programme: prog,
portfolio: port,
fundingOrganisation: recFundingOrg ?? null,
invitedOrganisation: recInvitedOrg ?? null,
linkedProject,
});
}
}
// Defensive check: If there are contracts that had no award associated, also render them
for (const { contract, award, fundingOrganisation, invitedOrganisation } of partnerSummary.contractEntries) {
if (contract && contract.id) {
const alreadyCovered = list.some(item => item.contract && String(item.contract.id) === String(contract.id));
if (!alreadyCovered) {
const synthAward: Award = award || {
id: contract.awardId || `c_aw_${contract.id}`,
organisationId: contract.tenantId || 1,
programmeId: 1,
amount: contract.totalValue || 0,
status: ApprovalStatus.Approved,
createdBy: contract.createdBy || 'System',
createdAt: contract.createdAt || new Date().toISOString(),
referenceNo: `AWD-CONT-${encodeHashIdSync(contract.id)}`,
};
list.push({
award: synthAward,
contract,
programme: null,
portfolio: allPortfolios.length > 0 ? allPortfolios[0] : null,
fundingOrganisation,
invitedOrganisation,
linkedProject: null,
});
}
}
}
return list;
}, [applicantDataList, partnerSummary, programmesMap, portfoliosMap, allPortfolios]);
const projectIdsKey = useMemo(
() => partnerSummary.projectEntries.map(e => String(e.project.id)).join(','),
[partnerSummary.projectEntries]
);
// Downstream queries: Milestones, Evidence, Activities, Disbursements
const { data: projectDetailsMap = {} } = useQuery<
const {
data: projectDetailsMap = {},
isFetching: isRefreshingProjectDetails,
refetch: refetchProjectDetails,
} = useQuery<
Record<
string,
{
@@ -373,6 +533,13 @@ export function DashboardPage() {
// Derived portfolio statistics for Analytics tab (Zero mock data)
const portfolioStats = useMemo(() => {
const totalProjects = enrichedProjects.length;
const activeProjects = enrichedProjects.filter(
p => Number(p.project.status) === ActivityStatus.Active || Number(p.project.status) === 2
).length;
const completedProjects = enrichedProjects.filter(
p => Number(p.project.status) === 6 || Number(p.project.status) === 7
).length;
const allMilestones = enrichedProjects.flatMap(p => p.milestones);
const completedMilestones = allMilestones.filter(m => Number(m.status) === ApprovalStatus.Approved);
const milestoneRate = allMilestones.length > 0
@@ -387,19 +554,45 @@ export function DashboardPage() {
.filter(d => Number(d.status) === ApprovalStatus.Approved)
.reduce((sum, d) => sum + Number(d.amount || 0), 0);
const totalEvidenceCount = enrichedProjects.flatMap(p => p.evidence).length;
const disbursementRate = totalFunding > 0 ? Math.min(100, Math.round((totalDisbursed / totalFunding) * 100)) : 0;
const allEvidence = enrichedProjects.flatMap(p => p.evidence);
const totalEvidenceCount = allEvidence.length;
return {
totalProjects,
activeProjects,
completedProjects,
totalMilestones: allMilestones.length,
completedMilestones: completedMilestones.length,
milestoneRate,
totalFunding,
totalDisbursed,
disbursementRate,
totalEvidenceCount,
allEvidence,
};
}, [enrichedProjects, partnerSummary]);
// Requirement #1: Refresh Portfolio Analytics without full page reload
const handleRefreshAnalytics = async () => {
setIsRefreshingAnalytics(true);
try {
await Promise.all([
refetchApplicantData(),
refetchProjectDetails(),
queryClient.invalidateQueries({ queryKey: ['partner-applicant-data'] }),
queryClient.invalidateQueries({ queryKey: ['partner-project-details'] }),
queryClient.invalidateQueries({ queryKey: ['all-programmes'] }),
queryClient.invalidateQueries({ queryKey: ['all-portfolios'] }),
]);
showSuccessToast('Analytics Refreshed', 'Portfolio performance and deliverable metrics are up to date.');
} catch (err: any) {
showErrorToast(err || 'Failed to refresh portfolio analytics.');
} finally {
setIsRefreshingAnalytics(false);
}
};
// Toggle in-place project expansion (Requirement #4)
const handleToggleExpand = (projectId: string | number) => {
const key = String(projectId);
@@ -1039,85 +1232,250 @@ export function DashboardPage() {
</div>
) : activeTab === 'contracts' ? (
/* ===================================================================
CONTRACTS & AWARDS TAB
CONTRACTS & APPROVED AWARDS TAB (Card per Award with Contract, Programme & Portfolio)
(Requirement #2)
=================================================================== */
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-white/[0.08] pb-4">
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-[20px] font-bold text-slate-100">Contracts & Approved Awards</h1>
<p className="text-[12px] text-slate-400 mt-0.5">
Review your approved funding awards and download or sign grant agreements.
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-mono font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
{enrichedAwards.length} {enrichedAwards.length === 1 ? 'Approved Award' : 'Approved Awards'}
</span>
</div>
<p className="text-[12px] text-slate-400 mt-1">
Full post-award governance oversight: Review your sanctioned grant awards, binding contracts, funding programmes, and governing portfolios.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{partnerSummary.contractEntries.length === 0 ? (
<div className="col-span-full rounded-[16px] bg-slate-900/80 border border-white/[0.06] p-10 text-center text-sm text-slate-400">
No contracts have been issued to your organisation yet.
<button
type="button"
onClick={handleRefreshAnalytics}
disabled={isRefreshingAnalytics || isRefreshingApplicantData || isRefreshingProjectDetails}
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-xl bg-white/[0.04] hover:bg-white/[0.08] border border-white/10 text-slate-300 hover:text-white text-xs font-semibold transition-all cursor-pointer shadow-sm disabled:opacity-50 shrink-0 self-start sm:self-auto"
title="Refresh awards, agreements and programmes"
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshingAnalytics || isRefreshingApplicantData || isRefreshingProjectDetails ? 'animate-spin' : ''}`} />
<span>{isRefreshingAnalytics ? 'Refreshing...' : 'Refresh Awards'}</span>
</button>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{enrichedAwards.length === 0 ? (
<div className="col-span-full rounded-2xl bg-slate-900/80 border border-white/[0.06] p-12 text-center space-y-3">
<div className="w-12 h-12 rounded-2xl bg-emerald-500/10 text-emerald-400 flex items-center justify-center mx-auto border border-emerald-500/20">
<AwardIcon className="w-6 h-6" />
</div>
<h3 className="text-base font-bold text-slate-100">No Approved Awards Issued Yet</h3>
<p className="text-xs text-slate-400 max-w-md mx-auto leading-relaxed">
Once your grant application is sanctioned by the approval committee, your official award record, governing programme, and legal contract agreement will appear here automatically.
</p>
<button
type="button"
onClick={handleRefreshAnalytics}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold transition-colors cursor-pointer mt-2"
>
<RefreshCw className="w-3.5 h-3.5" />
<span>Check for Awards</span>
</button>
</div>
) : (
partnerSummary.contractEntries.map(({ contract, award, fundingOrganisation }) => {
const isExecuted = Number(contract.status) === ContractStatus.Executed;
const contractVer = (contract as any).version || 1;
enrichedAwards.map(({ award, contract, programme, portfolio, fundingOrganisation, invitedOrganisation, linkedProject }, idx) => {
const isExecuted = contract ? Number(contract.status) === ContractStatus.Executed : false;
const contractVer = contract ? ((contract as any).version || 1) : null;
const awardHashId = encodeHashIdSync(award.id);
const awardRef = award.referenceNo || `AWD-${awardHashId || (idx + 1)}`;
const statusInfo = getApprovalStatusBadge(award.status);
return (
<div
key={contract.id}
className="rounded-[16px] bg-slate-900/80 border border-white/[0.06] p-6 flex flex-col justify-between gap-5"
key={`award-${award.id}-${idx}`}
className="rounded-2xl bg-slate-900/85 border border-white/[0.08] hover:border-emerald-500/30 transition-all p-6 sm:p-7 flex flex-col justify-between gap-6 shadow-xl shadow-black/20 backdrop-blur-xl group relative overflow-hidden"
>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-emerald-400 uppercase tracking-wider">
Agreement v{contractVer}
{/* Top ambient glow */}
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-48 h-48 bg-emerald-500/5 rounded-full blur-2xl pointer-events-none group-hover:bg-emerald-500/10 transition-colors" />
{/* SECTION 1: AWARD HEADER & METRICS */}
<div className="space-y-5 relative z-10">
{/* Row 1: Badges & Status */}
<div className="flex flex-wrap items-center justify-between gap-2.5">
<div className="flex items-center gap-2 flex-wrap">
<div className="p-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400">
<AwardIcon className="w-4 h-4" />
</div>
<span className="font-mono text-xs font-bold text-emerald-300 bg-emerald-950/80 border border-emerald-500/30 px-2.5 py-0.5 rounded-md">
#{awardRef}
</span>
<span className="text-[11px] font-semibold text-emerald-400 uppercase tracking-wider bg-emerald-500/10 px-2 py-0.5 rounded-md border border-emerald-500/20">
Approved Award
</span>
</div>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs ${statusInfo.className}`}>
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot}`} />
<span>{statusInfo.label}</span>
</span>
</div>
</div>
{/* Row 2: Organisation Titles */}
<div>
<h3 className="text-lg sm:text-xl font-bold text-white tracking-tight">
{fundingOrganisation?.name || 'Grant Funding Authority'}
</h3>
<p className="text-xs text-slate-400 mt-1">
Recipient Organisation:{' '}
<span className="text-slate-200 font-medium">
{invitedOrganisation?.name || partnerSummary.primaryInvitedOrg?.name || 'Partner Recipient'}
</span>
{award.createdAt && ` • Sanctioned: ${formatFullDate(award.createdAt)}`}
</p>
</div>
{/* Row 3: Financial Figures Card */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 p-4 rounded-xl bg-slate-950/60 border border-white/5">
<div>
<p className="text-[11px] font-medium text-slate-400 uppercase tracking-wider">Approved Award Capital</p>
<p className="text-xl font-bold text-emerald-400 mt-0.5">
{formatZAR(award.amount)}
</p>
<p className="text-[11px] text-slate-500 mt-0.5">Official Sanctioned Grant Amount</p>
</div>
<div>
<p className="text-[11px] font-medium text-slate-400 uppercase tracking-wider">Contracted Agreement Value</p>
<p className="text-xl font-bold text-teal-300 mt-0.5">
{formatZAR(contract?.totalValue || award.amount)}
</p>
<p className="text-[11px] text-slate-500 mt-0.5">
{contract ? `Binding Agreement Value (v${contractVer})` : 'Pending Contract Execution'}
</p>
</div>
</div>
{/* SECTION 2: PROGRAMME & PORTFOLIO GOVERNANCE HIERARCHY (Requirement #2) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
{/* Programme Box */}
<div className="p-3.5 rounded-xl bg-white/[0.02] border border-white/[0.06] space-y-1.5">
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold">
<Layers className="w-3.5 h-3.5" />
<span>Funding Programme</span>
</div>
<p className="text-sm font-semibold text-slate-200 line-clamp-1">
{programme?.name || award.programmeName || 'Strategic Development Programme'}
</p>
<p className="text-[11px] text-slate-400 font-mono">
ID: #{encodeHashIdSync(programme?.id || award.programmeId) || (award.programmeId ?? 'PROG')}
</p>
</div>
{/* Portfolio Box */}
<div className="p-3.5 rounded-xl bg-white/[0.02] border border-white/[0.06] space-y-1.5">
<div className="flex items-center gap-2 text-teal-400 text-xs font-semibold">
<Briefcase className="w-3.5 h-3.5" />
<span>Governing Portfolio</span>
</div>
<p className="text-sm font-semibold text-slate-200 line-clamp-1">
{portfolio?.name || 'National Impact Portfolio'}
</p>
<p className="text-[11px] text-slate-400 font-mono line-clamp-1">
{portfolio?.description || 'Strategic development and grant allocation portfolio.'}
</p>
</div>
</div>
{/* Linked Project Banner (if linked) */}
{linkedProject && (
<div className="flex items-center justify-between p-3 rounded-xl bg-emerald-500/[0.04] border border-emerald-500/20 text-xs">
<div className="flex items-center gap-2 min-w-0 pr-2">
<FolderKanban className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
<div className="min-w-0">
<span className="text-slate-400">Linked Project: </span>
<span className="font-semibold text-slate-200 truncate">{linkedProject.name}</span>
</div>
</div>
<span className="text-[11px] font-mono text-emerald-300 bg-emerald-950/60 border border-emerald-500/30 px-2 py-0.5 rounded">
#{encodeHashIdSync(linkedProject.id)}
</span>
</div>
)}
{/* SECTION 3: CONTRACT & AGREEMENT DETAILS (Requirement #2) */}
<div className="p-4 rounded-xl bg-slate-950/80 border border-white/[0.08] space-y-3">
{contract ? (
<>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/[0.06] pb-2.5">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-white tracking-wide">
Grant Agreement (v{contractVer})
</span>
<span className="text-[10px] font-mono text-slate-400">
ID: #{encodeHashIdSync(contract.id)}
</span>
</div>
<span
className={`px-2.5 py-0.5 rounded-full text-xs font-semibold ${
className={`px-2.5 py-0.5 rounded-full text-[11px] font-semibold ${
isExecuted
? 'bg-emerald-500/15 text-emerald-300 border border-emerald-500/30'
: 'bg-[#F97316]/15 text-[#F97316] border border-[#F97316]/30'
: 'bg-amber-500/15 text-amber-300 border border-amber-500/30'
}`}
>
{isExecuted ? 'Signed & Executed' : 'Action Required: Review & Sign'}
</span>
</div>
<h3 className="text-lg font-bold text-slate-100">
{fundingOrganisation?.name || 'Grant Funding Agreement'}
</h3>
<div className="grid grid-cols-2 gap-4 pt-2 text-xs">
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<p className="text-slate-400">Contract Value</p>
<p className="text-sm font-bold text-slate-100 mt-0.5">
{formatZAR(contract.totalValue || award?.amount || 0)}
</p>
</div>
<div>
<p className="text-slate-400">Valid Period</p>
<p className="text-[#D1CBDD] font-medium mt-0.5">
<p className="text-[11px] text-slate-400">Contract Validity Period</p>
<p className="text-slate-200 font-medium mt-0.5">
{formatShortDate(contract.effectiveAt)} – {formatFullDate(contract.expiresAt)}
</p>
</div>
<div>
<p className="text-[11px] text-slate-400">Digital Execution</p>
<p className="text-slate-200 font-medium mt-0.5">
{contract.externalSignatureId ? `Ref: ${contract.externalSignatureId}` : (isExecuted ? 'Countersigned' : 'Awaiting Signature')}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 pt-4 border-t border-white/[0.06]">
{/* Contract Action Buttons */}
<div className="flex flex-wrap items-center gap-2.5 pt-2">
<button
type="button"
onClick={() => handleOpenSecureFile('contract', contract.id)}
className="flex-1 py-2.5 px-4 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-slate-100 text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer"
className="flex-1 py-2 px-3.5 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-slate-200 hover:text-white text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer border border-white/5"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>Download PDF</span>
<ExternalLink className="w-3.5 h-3.5 text-emerald-400" />
<span>Download Agreement PDF</span>
</button>
{!isExecuted && canAdd('grant_financials') && (
<button
type="button"
onClick={() => signContractMutation.mutate(contract)}
disabled={signContractMutation.isPending}
className="flex-1 py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer disabled:opacity-50"
className="flex-1 py-2 px-3.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer disabled:opacity-50 shadow-sm shadow-emerald-600/30"
>
<CheckCircle2 className="w-3.5 h-3.5" />
<span>{signContractMutation.isPending ? 'Signing...' : 'Accept & Sign'}</span>
<span>{signContractMutation.isPending ? 'Signing...' : 'Accept & Sign Agreement'}</span>
</button>
)}
</div>
</>
) : (
<div className="flex items-start gap-3 py-1">
<Hourglass className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div className="space-y-0.5">
<p className="text-xs font-semibold text-amber-300">Contract In Preparation (BR-002)</p>
<p className="text-[11px] text-slate-400 leading-relaxed">
The formal legal agreement for this approved award is being generated by the Funding PMO. Once generated, it will appear here for review and digital signature.
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
})
@@ -1126,48 +1484,235 @@ export function DashboardPage() {
</div>
) : activeTab === 'analytics' ? (
/* ===================================================================
ANALYTICS TAB (Zero Mock Data: Real API Derived Values)
ANALYTICS TAB (Real API Derived Portfolio Metrics with Refresh Button)
(Requirement #1)
=================================================================== */
<div className="space-y-6">
{/* Header with Refresh Button */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-white/[0.08] pb-4">
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-[20px] font-bold text-slate-100">Portfolio Analytics</h1>
<p className="text-[12px] text-slate-400 mt-0.5">
Real-time performance metrics and deliverable fulfillment rates across your assigned projects.
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-mono font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
Live Performance
</span>
</div>
<p className="text-[12px] text-slate-400 mt-1">
Real-time performance metrics, milestone completion rates, and capital utilization across your assigned projects.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{/* Requirement #1: Refresh Button */}
<button
type="button"
onClick={handleRefreshAnalytics}
disabled={isRefreshingAnalytics || isRefreshingApplicantData || isRefreshingProjectDetails}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-500/15 hover:bg-emerald-500/25 border border-emerald-500/30 text-emerald-300 hover:text-emerald-200 text-xs font-semibold transition-all cursor-pointer shadow-sm hover:shadow-[0_0_15px_rgba(16,185,129,0.25)] disabled:opacity-50 shrink-0 self-start sm:self-auto"
title="Refresh portfolio performance and deliverable metrics"
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshingAnalytics || isRefreshingApplicantData || isRefreshingProjectDetails ? 'animate-spin' : ''}`} />
<span>{isRefreshingAnalytics ? 'Refreshing Data...' : 'Refresh Analytics'}</span>
</button>
</div>
{/* Top KPI Metric Cards (4 Cards Grid) */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
{/* Card 1: Milestone Fulfillment Rate */}
<div className="p-6 rounded-[16px] bg-slate-900/80 border border-white/[0.06]">
<div className="p-5 rounded-2xl bg-slate-900/80 border border-white/[0.06] relative overflow-hidden flex flex-col justify-between">
<div>
<div className="flex items-center justify-between text-emerald-400 mb-2">
<TrendingUp className="w-5 h-5" />
<span className="text-xs font-semibold text-emerald-400">
{portfolioStats.completedMilestones} of {portfolioStats.totalMilestones} Milestones
<span className="text-xs font-semibold text-emerald-400 font-mono">
{portfolioStats.completedMilestones} / {portfolioStats.totalMilestones} Verified
</span>
</div>
<p className="text-2xl font-bold text-slate-100">{portfolioStats.milestoneRate}%</p>
<p className="text-xs text-slate-400 mt-1">Milestone Completion Rate</p>
<p className="text-3xl font-extrabold text-white tracking-tight">{portfolioStats.milestoneRate}%</p>
<p className="text-xs text-slate-400 mt-1 font-medium">Milestone Delivery Rate</p>
</div>
<div className="w-full bg-slate-950/80 h-1.5 rounded-full overflow-hidden mt-4">
<div
className="bg-emerald-400 h-full rounded-full transition-all duration-500"
style={{ width: `${Math.min(100, Math.max(0, portfolioStats.milestoneRate))}%` }}
/>
</div>
</div>
{/* Card 2: Active Projects */}
<div className="p-6 rounded-[16px] bg-slate-900/80 border border-white/[0.06]">
<div className="p-5 rounded-2xl bg-slate-900/80 border border-white/[0.06] relative overflow-hidden flex flex-col justify-between">
<div>
<div className="flex items-center justify-between text-teal-400 mb-2">
<Briefcase className="w-5 h-5" />
<span className="text-xs font-semibold text-emerald-400">Active</span>
<span className="text-xs font-semibold text-teal-300 font-mono">
{portfolioStats.activeProjects} Active
</span>
</div>
<p className="text-2xl font-bold text-slate-100">{portfolioStats.totalProjects} Projects</p>
<p className="text-xs text-slate-400 mt-1">Assigned Funded Projects</p>
<p className="text-3xl font-extrabold text-white tracking-tight">{portfolioStats.totalProjects}</p>
<p className="text-xs text-slate-400 mt-1 font-medium">Funded Projects in Execution</p>
</div>
<p className="text-[11px] text-slate-500 mt-4">
{portfolioStats.completedProjects} Completed • {Math.max(0, portfolioStats.totalProjects - portfolioStats.activeProjects - portfolioStats.completedProjects)} In Preparation
</p>
</div>
{/* Card 3: Total Supporting Proofs */}
<div className="p-6 rounded-[16px] bg-slate-900/80 border border-white/[0.06]">
{/* Card 3: Capital Allocation & Drawdowns */}
<div className="p-5 rounded-2xl bg-slate-900/80 border border-white/[0.06] relative overflow-hidden flex flex-col justify-between">
<div>
<div className="flex items-center justify-between text-cyan-400 mb-2">
<DollarSign className="w-5 h-5" />
<span className="text-xs font-semibold text-cyan-300 font-mono">
{portfolioStats.disbursementRate}% Disbursed
</span>
</div>
<p className="text-2xl font-extrabold text-white tracking-tight">
{formatCompactZAR(portfolioStats.totalDisbursed)}
</p>
<p className="text-xs text-slate-400 mt-1 font-medium">
of {formatCompactZAR(portfolioStats.totalFunding)} Total Capital
</p>
</div>
<div className="w-full bg-slate-950/80 h-1.5 rounded-full overflow-hidden mt-4">
<div
className="bg-cyan-400 h-full rounded-full transition-all duration-500"
style={{ width: `${Math.min(100, Math.max(0, portfolioStats.disbursementRate))}%` }}
/>
</div>
</div>
{/* Card 4: Audited Deliverables & Evidence */}
<div className="p-5 rounded-2xl bg-slate-900/80 border border-white/[0.06] relative overflow-hidden flex flex-col justify-between">
<div>
<div className="flex items-center justify-between text-amber-400 mb-2">
<AwardIcon className="w-5 h-5" />
<span className="text-xs font-semibold text-amber-400">Audited</span>
<span className="text-xs font-semibold text-amber-300 font-mono">Audited</span>
</div>
<p className="text-2xl font-bold text-slate-100">{portfolioStats.totalEvidenceCount} Documents</p>
<p className="text-xs text-slate-400 mt-1">Uploaded Evidence Deliverables</p>
<p className="text-3xl font-extrabold text-white tracking-tight">{portfolioStats.totalEvidenceCount}</p>
<p className="text-xs text-slate-400 mt-1 font-medium">Uploaded Evidence Deliverables</p>
</div>
<p className="text-[11px] text-slate-500 mt-4">
Substantiating milestone and indicator outcomes
</p>
</div>
</div>
{/* Progress Bars Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Delivery Progress Bar Card */}
<div className="p-6 rounded-2xl bg-slate-900/80 border border-white/[0.06] space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-emerald-400" />
<h3 className="text-sm font-bold text-white">Milestone Delivery Velocity</h3>
</div>
<span className="font-mono text-sm font-bold text-emerald-400">
{portfolioStats.completedMilestones} / {portfolioStats.totalMilestones} ({portfolioStats.milestoneRate}%)
</span>
</div>
<div className="w-full bg-slate-950 h-3 rounded-full overflow-hidden p-0.5 border border-white/5">
<div
className="bg-gradient-to-r from-teal-500 to-emerald-400 h-full rounded-full transition-all duration-700 shadow-sm shadow-emerald-500/50"
style={{ width: `${Math.min(100, Math.max(0, portfolioStats.milestoneRate))}%` }}
/>
</div>
<p className="text-xs text-slate-400 leading-relaxed">
Percentage of contractual project milestones formally approved and verified through uploaded deliverable proofs.
</p>
</div>
{/* Capital Drawdown Bar Card */}
<div className="p-6 rounded-2xl bg-slate-900/80 border border-white/[0.06] space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4 text-cyan-400" />
<h3 className="text-sm font-bold text-white">Disbursement vs Total Allocation</h3>
</div>
<span className="font-mono text-sm font-bold text-cyan-400">
{formatZAR(portfolioStats.totalDisbursed)} / {formatZAR(portfolioStats.totalFunding)}
</span>
</div>
<div className="w-full bg-slate-950 h-3 rounded-full overflow-hidden p-0.5 border border-white/5">
<div
className="bg-gradient-to-r from-cyan-500 to-emerald-400 h-full rounded-full transition-all duration-700 shadow-sm shadow-cyan-500/50"
style={{ width: `${Math.min(100, Math.max(0, portfolioStats.disbursementRate))}%` }}
/>
</div>
<p className="text-xs text-slate-400 leading-relaxed">
Tranche payments released following conditions precedent fulfillment and milestone verification ({portfolioStats.disbursementRate}% capital utilization).
</p>
</div>
</div>
{/* Project Performance Breakdown Table */}
<div className="rounded-2xl bg-slate-900/80 border border-white/[0.06] overflow-hidden">
<div className="p-5 border-b border-white/[0.06] flex items-center justify-between">
<div className="flex items-center gap-2">
<PieChart className="w-4 h-4 text-emerald-400" />
<h3 className="text-sm font-bold text-white">Project-Level Performance Summary</h3>
</div>
<span className="text-xs text-slate-400 font-mono">
{enrichedProjects.length} Projects Tracked
</span>
</div>
{enrichedProjects.length === 0 ? (
<div className="p-8 text-center text-xs text-slate-400">
No active projects assigned to display performance analytics.
</div>
) : (
<div className="divide-y divide-white/[0.06]">
{enrichedProjects.map(item => {
const projectMilestones = item.milestones || [];
const approvedM = projectMilestones.filter(m => Number(m.status) === ApprovalStatus.Approved).length;
const rate = projectMilestones.length > 0 ? Math.round((approvedM / projectMilestones.length) * 100) : 100;
const projectDisbursed = (item.disbursements || [])
.filter(d => Number(d.status) === ApprovalStatus.Approved)
.reduce((sum, d) => sum + Number(d.amount || 0), 0);
return (
<div key={item.project.id} className="p-4 sm:p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-white/[0.02] transition-colors">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-xs font-bold text-emerald-300 bg-emerald-950/80 border border-emerald-500/30 px-2 py-0.5 rounded">
#{encodeHashIdSync(item.project.id)}
</span>
<h4 className="text-sm font-bold text-slate-100 truncate">{item.project.name}</h4>
</div>
<p className="text-xs text-slate-400">
Funder: <span className="text-slate-300 font-medium">{item.fundingOrganisation?.name || 'Programme Office'}</span>
{' • '}
Grant: <strong className="text-emerald-400">{formatZAR(item.contract?.totalValue || item.award?.amount || 0)}</strong>
</p>
</div>
<div className="flex items-center gap-6 shrink-0 text-xs">
<div>
<span className="text-[10px] text-slate-500 uppercase font-bold block">Milestone Progress</span>
<div className="flex items-center gap-2 mt-0.5">
<div className="w-20 bg-slate-950 h-2 rounded-full overflow-hidden">
<div className="bg-emerald-400 h-full rounded-full" style={{ width: `${rate}%` }} />
</div>
<span className="font-mono font-semibold text-emerald-300">{rate}%</span>
</div>
</div>
<div className="text-right">
<span className="text-[10px] text-slate-500 uppercase font-bold block">Disbursed</span>
<span className="font-mono font-bold text-cyan-300 mt-0.5 block">
{formatCompactZAR(projectDisbursed)}
</span>
</div>
<div className="text-right">
<span className="text-[10px] text-slate-500 uppercase font-bold block">Evidence</span>
<span className="font-mono font-bold text-amber-300 mt-0.5 block">
{item.evidence.length} Files
</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
) : (