Added Portfolio Analytics refresh button and card-per-award with contract, programme, and portfolio details
This commit is contained in:
@@ -30,6 +30,9 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
Reply,
|
Reply,
|
||||||
Mail,
|
Mail,
|
||||||
|
Layers,
|
||||||
|
FolderKanban,
|
||||||
|
PieChart,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../../auth/AuthProvider';
|
import { useAuth } from '../../auth/AuthProvider';
|
||||||
import { useRbac } from '../../auth/rbac';
|
import { useRbac } from '../../auth/rbac';
|
||||||
@@ -47,6 +50,8 @@ import {
|
|||||||
getEvidenceSecureLink,
|
getEvidenceSecureLink,
|
||||||
submitActivityForApproval,
|
submitActivityForApproval,
|
||||||
resolveUserIdByEmail,
|
resolveUserIdByEmail,
|
||||||
|
getAllProgrammes,
|
||||||
|
getPortfolios,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import {
|
import {
|
||||||
ApplicantData,
|
ApplicantData,
|
||||||
@@ -58,6 +63,8 @@ import {
|
|||||||
Contract,
|
Contract,
|
||||||
Award,
|
Award,
|
||||||
Organisation,
|
Organisation,
|
||||||
|
Programme,
|
||||||
|
Portfolio,
|
||||||
ApprovalStatus,
|
ApprovalStatus,
|
||||||
ActivityStatus,
|
ActivityStatus,
|
||||||
ContractStatus,
|
ContractStatus,
|
||||||
@@ -66,7 +73,14 @@ import {
|
|||||||
NotificationStatus,
|
NotificationStatus,
|
||||||
NotificationPlatform,
|
NotificationPlatform,
|
||||||
} from '../../types';
|
} 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 { MessageViewer } from '../notifications/MessageViewer';
|
||||||
import { stripMarkdown } from '../../lib/markdownUtils';
|
import { stripMarkdown } from '../../lib/markdownUtils';
|
||||||
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
|
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
|
||||||
@@ -74,6 +88,16 @@ import { ProjectCard, EnrichedPartnerProject } from './ProjectCard';
|
|||||||
import { EvidenceUploadModal } from './EvidenceUploadModal';
|
import { EvidenceUploadModal } from './EvidenceUploadModal';
|
||||||
import { PartnerMessageComposerModal } from '../notifications/PartnerMessageComposerModal';
|
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 NavTab = 'dashboard' | 'messages' | 'contracts' | 'analytics' | 'documents';
|
||||||
type SortOption = 'date_added' | 'grant_amount' | 'days_left' | 'name';
|
type SortOption = 'date_added' | 'grant_amount' | 'days_left' | 'name';
|
||||||
|
|
||||||
@@ -160,6 +184,7 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
const [contractFile, setContractFile] = useState<File | null>(null);
|
const [contractFile, setContractFile] = useState<File | null>(null);
|
||||||
const [signatureRef, setSignatureRef] = useState('');
|
const [signatureRef, setSignatureRef] = useState('');
|
||||||
|
const [isRefreshingAnalytics, setIsRefreshingAnalytics] = useState(false);
|
||||||
|
|
||||||
// Contact / Message form state
|
// Contact / Message form state
|
||||||
const [messageSubject, setMessageSubject] = useState('');
|
const [messageSubject, setMessageSubject] = useState('');
|
||||||
@@ -263,13 +288,148 @@ export function DashboardPage() {
|
|||||||
};
|
};
|
||||||
}, [applicantDataList]);
|
}, [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(
|
const projectIdsKey = useMemo(
|
||||||
() => partnerSummary.projectEntries.map(e => String(e.project.id)).join(','),
|
() => partnerSummary.projectEntries.map(e => String(e.project.id)).join(','),
|
||||||
[partnerSummary.projectEntries]
|
[partnerSummary.projectEntries]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Downstream queries: Milestones, Evidence, Activities, Disbursements
|
// Downstream queries: Milestones, Evidence, Activities, Disbursements
|
||||||
const { data: projectDetailsMap = {} } = useQuery<
|
const {
|
||||||
|
data: projectDetailsMap = {},
|
||||||
|
isFetching: isRefreshingProjectDetails,
|
||||||
|
refetch: refetchProjectDetails,
|
||||||
|
} = useQuery<
|
||||||
Record<
|
Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -373,6 +533,13 @@ export function DashboardPage() {
|
|||||||
// Derived portfolio statistics for Analytics tab (Zero mock data)
|
// Derived portfolio statistics for Analytics tab (Zero mock data)
|
||||||
const portfolioStats = useMemo(() => {
|
const portfolioStats = useMemo(() => {
|
||||||
const totalProjects = enrichedProjects.length;
|
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 allMilestones = enrichedProjects.flatMap(p => p.milestones);
|
||||||
const completedMilestones = allMilestones.filter(m => Number(m.status) === ApprovalStatus.Approved);
|
const completedMilestones = allMilestones.filter(m => Number(m.status) === ApprovalStatus.Approved);
|
||||||
const milestoneRate = allMilestones.length > 0
|
const milestoneRate = allMilestones.length > 0
|
||||||
@@ -387,19 +554,45 @@ export function DashboardPage() {
|
|||||||
.filter(d => Number(d.status) === ApprovalStatus.Approved)
|
.filter(d => Number(d.status) === ApprovalStatus.Approved)
|
||||||
.reduce((sum, d) => sum + Number(d.amount || 0), 0);
|
.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 {
|
return {
|
||||||
totalProjects,
|
totalProjects,
|
||||||
|
activeProjects,
|
||||||
|
completedProjects,
|
||||||
totalMilestones: allMilestones.length,
|
totalMilestones: allMilestones.length,
|
||||||
completedMilestones: completedMilestones.length,
|
completedMilestones: completedMilestones.length,
|
||||||
milestoneRate,
|
milestoneRate,
|
||||||
totalFunding,
|
totalFunding,
|
||||||
totalDisbursed,
|
totalDisbursed,
|
||||||
|
disbursementRate,
|
||||||
totalEvidenceCount,
|
totalEvidenceCount,
|
||||||
|
allEvidence,
|
||||||
};
|
};
|
||||||
}, [enrichedProjects, partnerSummary]);
|
}, [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)
|
// Toggle in-place project expansion (Requirement #4)
|
||||||
const handleToggleExpand = (projectId: string | number) => {
|
const handleToggleExpand = (projectId: string | number) => {
|
||||||
const key = String(projectId);
|
const key = String(projectId);
|
||||||
@@ -1039,85 +1232,250 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : activeTab === 'contracts' ? (
|
) : 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="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>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
<h1 className="text-[20px] font-bold text-slate-100">Contracts & Approved Awards</h1>
|
<h1 className="text-[20px] font-bold text-slate-100">Contracts & Approved Awards</h1>
|
||||||
<p className="text-[12px] text-slate-400 mt-0.5">
|
<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">
|
||||||
Review your approved funding awards and download or sign grant agreements.
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<button
|
||||||
{partnerSummary.contractEntries.length === 0 ? (
|
type="button"
|
||||||
<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">
|
onClick={handleRefreshAnalytics}
|
||||||
No contracts have been issued to your organisation yet.
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
partnerSummary.contractEntries.map(({ contract, award, fundingOrganisation }) => {
|
enrichedAwards.map(({ award, contract, programme, portfolio, fundingOrganisation, invitedOrganisation, linkedProject }, idx) => {
|
||||||
const isExecuted = Number(contract.status) === ContractStatus.Executed;
|
const isExecuted = contract ? Number(contract.status) === ContractStatus.Executed : false;
|
||||||
const contractVer = (contract as any).version || 1;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={contract.id}
|
key={`award-${award.id}-${idx}`}
|
||||||
className="rounded-[16px] bg-slate-900/80 border border-white/[0.06] p-6 flex flex-col justify-between gap-5"
|
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">
|
{/* Top ambient glow */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<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" />
|
||||||
<span className="text-xs font-semibold text-emerald-400 uppercase tracking-wider">
|
|
||||||
Agreement v{contractVer}
|
{/* 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>
|
||||||
|
<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
|
<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
|
isExecuted
|
||||||
? 'bg-emerald-500/15 text-emerald-300 border border-emerald-500/30'
|
? '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'}
|
{isExecuted ? 'Signed & Executed' : 'Action Required: Review & Sign'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-bold text-slate-100">
|
|
||||||
{fundingOrganisation?.name || 'Grant Funding Agreement'}
|
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4 pt-2 text-xs">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-400">Contract Value</p>
|
<p className="text-[11px] text-slate-400">Contract Validity Period</p>
|
||||||
<p className="text-sm font-bold text-slate-100 mt-0.5">
|
<p className="text-slate-200 font-medium 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">
|
|
||||||
{formatShortDate(contract.effectiveAt)} – {formatFullDate(contract.expiresAt)}
|
{formatShortDate(contract.effectiveAt)} – {formatFullDate(contract.expiresAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOpenSecureFile('contract', contract.id)}
|
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" />
|
<ExternalLink className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
<span>Download PDF</span>
|
<span>Download Agreement PDF</span>
|
||||||
</button>
|
</button>
|
||||||
{!isExecuted && canAdd('grant_financials') && (
|
{!isExecuted && canAdd('grant_financials') && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => signContractMutation.mutate(contract)}
|
onClick={() => signContractMutation.mutate(contract)}
|
||||||
disabled={signContractMutation.isPending}
|
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" />
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
<span>{signContractMutation.isPending ? 'Signing...' : 'Accept & Sign'}</span>
|
<span>{signContractMutation.isPending ? 'Signing...' : 'Accept & Sign Agreement'}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -1126,48 +1484,235 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : activeTab === 'analytics' ? (
|
) : 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">
|
<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>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
<h1 className="text-[20px] font-bold text-slate-100">Portfolio Analytics</h1>
|
<h1 className="text-[20px] font-bold text-slate-100">Portfolio Analytics</h1>
|
||||||
<p className="text-[12px] text-slate-400 mt-0.5">
|
<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">
|
||||||
Real-time performance metrics and deliverable fulfillment rates across your assigned projects.
|
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>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* 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">
|
<div className="flex items-center justify-between text-emerald-400 mb-2">
|
||||||
<TrendingUp className="w-5 h-5" />
|
<TrendingUp className="w-5 h-5" />
|
||||||
<span className="text-xs font-semibold text-emerald-400">
|
<span className="text-xs font-semibold text-emerald-400 font-mono">
|
||||||
{portfolioStats.completedMilestones} of {portfolioStats.totalMilestones} Milestones
|
{portfolioStats.completedMilestones} / {portfolioStats.totalMilestones} Verified
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-slate-100">{portfolioStats.milestoneRate}%</p>
|
<p className="text-3xl font-extrabold text-white tracking-tight">{portfolioStats.milestoneRate}%</p>
|
||||||
<p className="text-xs text-slate-400 mt-1">Milestone Completion Rate</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>
|
</div>
|
||||||
|
|
||||||
{/* Card 2: Active Projects */}
|
{/* 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">
|
<div className="flex items-center justify-between text-teal-400 mb-2">
|
||||||
<Briefcase className="w-5 h-5" />
|
<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>
|
</div>
|
||||||
<p className="text-2xl font-bold text-slate-100">{portfolioStats.totalProjects} Projects</p>
|
<p className="text-3xl font-extrabold text-white tracking-tight">{portfolioStats.totalProjects}</p>
|
||||||
<p className="text-xs text-slate-400 mt-1">Assigned Funded Projects</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>
|
</div>
|
||||||
|
|
||||||
{/* Card 3: Total Supporting Proofs */}
|
{/* Card 3: Capital Allocation & Drawdowns */}
|
||||||
<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-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">
|
<div className="flex items-center justify-between text-amber-400 mb-2">
|
||||||
<AwardIcon className="w-5 h-5" />
|
<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>
|
</div>
|
||||||
<p className="text-2xl font-bold text-slate-100">{portfolioStats.totalEvidenceCount} Documents</p>
|
<p className="text-3xl font-extrabold text-white tracking-tight">{portfolioStats.totalEvidenceCount}</p>
|
||||||
<p className="text-xs text-slate-400 mt-1">Uploaded Evidence Deliverables</p>
|
<p className="text-xs text-slate-400 mt-1 font-medium">Uploaded Evidence Deliverables</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user