From 80c798e5e694d1fade39bc226aa1f30e51082512 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 26 Oct 2026 17:45:52 +0200 Subject: [PATCH] Added Portfolio Analytics refresh button and card-per-award with contract, programme, and portfolio details --- .../components/dashboard/DashboardPage.tsx | 745 +++++++++++++++--- 1 file changed, 645 insertions(+), 100 deletions(-) diff --git a/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx b/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx index e584410..63d26a6 100644 --- a/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx +++ b/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx @@ -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(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({ + 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({ + queryKey: ['all-portfolios'], + queryFn: () => getPortfolios(), + staleTime: 5 * 60 * 1000, + }); + + const programmesMap = useMemo(() => { + const map = new Map(); + 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(); + 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(); + + 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,84 +1232,249 @@ export function DashboardPage() { ) : activeTab === 'contracts' ? ( /* =================================================================== - CONTRACTS & AWARDS TAB + CONTRACTS & APPROVED AWARDS TAB (Card per Award with Contract, Programme & Portfolio) + (Requirement #2) =================================================================== */
-
-

Contracts & Approved Awards

-

- Review your approved funding awards and download or sign grant agreements. -

+
+
+
+

Contracts & Approved Awards

+ + {enrichedAwards.length} {enrichedAwards.length === 1 ? 'Approved Award' : 'Approved Awards'} + +
+

+ Full post-award governance oversight: Review your sanctioned grant awards, binding contracts, funding programmes, and governing portfolios. +

+
+ +
-
- {partnerSummary.contractEntries.length === 0 ? ( -
- No contracts have been issued to your organisation yet. +
+ {enrichedAwards.length === 0 ? ( +
+
+ +
+

No Approved Awards Issued Yet

+

+ Once your grant application is sanctioned by the approval committee, your official award record, governing programme, and legal contract agreement will appear here automatically. +

+
) : ( - 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 (
-
-
- - Agreement v{contractVer} - - - {isExecuted ? 'Signed & Executed' : 'Action Required: Review & Sign'} - -
-

- {fundingOrganisation?.name || 'Grant Funding Agreement'} -

-
-
-

Contract Value

-

- {formatZAR(contract.totalValue || award?.amount || 0)} -

-
-
-

Valid Period

-

- {formatShortDate(contract.effectiveAt)} – {formatFullDate(contract.expiresAt)} -

-
-
-
+ {/* Top ambient glow */} +
-
- - {!isExecuted && canAdd('grant_financials') && ( - + {/* SECTION 1: AWARD HEADER & METRICS */} +
+ {/* Row 1: Badges & Status */} +
+
+
+ +
+ + #{awardRef} + + + Approved Award + +
+ +
+ + + {statusInfo.label} + +
+
+ + {/* Row 2: Organisation Titles */} +
+

+ {fundingOrganisation?.name || 'Grant Funding Authority'} +

+

+ Recipient Organisation:{' '} + + {invitedOrganisation?.name || partnerSummary.primaryInvitedOrg?.name || 'Partner Recipient'} + + {award.createdAt && ` • Sanctioned: ${formatFullDate(award.createdAt)}`} +

+
+ + {/* Row 3: Financial Figures Card */} +
+
+

Approved Award Capital

+

+ {formatZAR(award.amount)} +

+

Official Sanctioned Grant Amount

+
+
+

Contracted Agreement Value

+

+ {formatZAR(contract?.totalValue || award.amount)} +

+

+ {contract ? `Binding Agreement Value (v${contractVer})` : 'Pending Contract Execution'} +

+
+
+ + {/* SECTION 2: PROGRAMME & PORTFOLIO GOVERNANCE HIERARCHY (Requirement #2) */} +
+ {/* Programme Box */} +
+
+ + Funding Programme +
+

+ {programme?.name || award.programmeName || 'Strategic Development Programme'} +

+

+ ID: #{encodeHashIdSync(programme?.id || award.programmeId) || (award.programmeId ?? 'PROG')} +

+
+ + {/* Portfolio Box */} +
+
+ + Governing Portfolio +
+

+ {portfolio?.name || 'National Impact Portfolio'} +

+

+ {portfolio?.description || 'Strategic development and grant allocation portfolio.'} +

+
+
+ + {/* Linked Project Banner (if linked) */} + {linkedProject && ( +
+
+ +
+ Linked Project: + {linkedProject.name} +
+
+ + #{encodeHashIdSync(linkedProject.id)} + +
)} + + {/* SECTION 3: CONTRACT & AGREEMENT DETAILS (Requirement #2) */} +
+ {contract ? ( + <> +
+
+ + Grant Agreement (v{contractVer}) + + + ID: #{encodeHashIdSync(contract.id)} + +
+ + {isExecuted ? 'Signed & Executed' : 'Action Required: Review & Sign'} + +
+ +
+
+

Contract Validity Period

+

+ {formatShortDate(contract.effectiveAt)} – {formatFullDate(contract.expiresAt)} +

+
+
+

Digital Execution

+

+ {contract.externalSignatureId ? `Ref: ${contract.externalSignatureId}` : (isExecuted ? 'Countersigned' : 'Awaiting Signature')} +

+
+
+ + {/* Contract Action Buttons */} +
+ + {!isExecuted && canAdd('grant_financials') && ( + + )} +
+ + ) : ( +
+ +
+

Contract In Preparation (BR-002)

+

+ 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. +

+
+
+ )} +
); @@ -1126,48 +1484,235 @@ export function DashboardPage() {
) : activeTab === 'analytics' ? ( /* =================================================================== - ANALYTICS TAB (Zero Mock Data: Real API Derived Values) + ANALYTICS TAB (Real API Derived Portfolio Metrics with Refresh Button) + (Requirement #1) =================================================================== */
-
-

Portfolio Analytics

-

- Real-time performance metrics and deliverable fulfillment rates across your assigned projects. -

-
- -
- {/* Card 1: Milestone Fulfillment Rate */} -
-
- - - {portfolioStats.completedMilestones} of {portfolioStats.totalMilestones} Milestones + {/* Header with Refresh Button */} +
+
+
+

Portfolio Analytics

+ + Live Performance
-

{portfolioStats.milestoneRate}%

-

Milestone Completion Rate

+

+ Real-time performance metrics, milestone completion rates, and capital utilization across your assigned projects. +

+
+ + {/* Requirement #1: Refresh Button */} + +
+ + {/* Top KPI Metric Cards (4 Cards Grid) */} +
+ {/* Card 1: Milestone Fulfillment Rate */} +
+
+
+ + + {portfolioStats.completedMilestones} / {portfolioStats.totalMilestones} Verified + +
+

{portfolioStats.milestoneRate}%

+

Milestone Delivery Rate

+
+
+
+
{/* Card 2: Active Projects */} -
-
- - Active +
+
+
+ + + {portfolioStats.activeProjects} Active + +
+

{portfolioStats.totalProjects}

+

Funded Projects in Execution

-

{portfolioStats.totalProjects} Projects

-

Assigned Funded Projects

+

+ {portfolioStats.completedProjects} Completed • {Math.max(0, portfolioStats.totalProjects - portfolioStats.activeProjects - portfolioStats.completedProjects)} In Preparation +

- {/* Card 3: Total Supporting Proofs */} -
-
- - Audited + {/* Card 3: Capital Allocation & Drawdowns */} +
+
+
+ + + {portfolioStats.disbursementRate}% Disbursed + +
+

+ {formatCompactZAR(portfolioStats.totalDisbursed)} +

+

+ of {formatCompactZAR(portfolioStats.totalFunding)} Total Capital +

+
+
+
-

{portfolioStats.totalEvidenceCount} Documents

-

Uploaded Evidence Deliverables

+ + {/* Card 4: Audited Deliverables & Evidence */} +
+
+
+ + Audited +
+

{portfolioStats.totalEvidenceCount}

+

Uploaded Evidence Deliverables

+
+

+ Substantiating milestone and indicator outcomes +

+
+
+ + {/* Progress Bars Section */} +
+ {/* Delivery Progress Bar Card */} +
+
+
+ +

Milestone Delivery Velocity

+
+ + {portfolioStats.completedMilestones} / {portfolioStats.totalMilestones} ({portfolioStats.milestoneRate}%) + +
+
+
+
+

+ Percentage of contractual project milestones formally approved and verified through uploaded deliverable proofs. +

+
+ + {/* Capital Drawdown Bar Card */} +
+
+
+ +

Disbursement vs Total Allocation

+
+ + {formatZAR(portfolioStats.totalDisbursed)} / {formatZAR(portfolioStats.totalFunding)} + +
+
+
+
+

+ Tranche payments released following conditions precedent fulfillment and milestone verification ({portfolioStats.disbursementRate}% capital utilization). +

+
+
+ + {/* Project Performance Breakdown Table */} +
+
+
+ +

Project-Level Performance Summary

+
+ + {enrichedProjects.length} Projects Tracked + +
+ + {enrichedProjects.length === 0 ? ( +
+ No active projects assigned to display performance analytics. +
+ ) : ( +
+ {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 ( +
+
+
+ + #{encodeHashIdSync(item.project.id)} + +

{item.project.name}

+
+

+ Funder: {item.fundingOrganisation?.name || 'Programme Office'} + {' • '} + Grant: {formatZAR(item.contract?.totalValue || item.award?.amount || 0)} +

+
+ +
+
+ Milestone Progress +
+
+
+
+ {rate}% +
+
+ +
+ Disbursed + + {formatCompactZAR(projectDisbursed)} + +
+ +
+ Evidence + + {item.evidence.length} Files + +
+
+
+ ); + })} +
+ )}
) : (