import React, { useState, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Search, Trash2, Bell, LogOut, Calendar, DollarSign, Paperclip, Hourglass, Clock, FileText, Upload, Send, ChevronDown, ShieldCheck, MessageSquare, X, Plus, ExternalLink, CheckCircle2, RefreshCw, Briefcase, TrendingUp, Award as AwardIcon, Info, Sparkles, Infinity as InfinityIcon, Menu, Reply, Mail, Layers, FolderKanban, PieChart, } from 'lucide-react'; import { useAuth } from '../../auth/AuthProvider'; import { useRbac } from '../../auth/rbac'; import { useToast } from '../ui/Toast'; import { useInAppNotifications } from '../../hooks/useInAppNotifications'; import { getApplicantData, getMilestones, getProjectEvidence, getActivities, getDisbursements, uploadContractFile, executeContract, getContractSecureLink, getEvidenceSecureLink, submitActivityForApproval, resolveUserIdByEmail, getAllProgrammes, getPortfolios, } from '../../services/api'; import { ApplicantData, Project, Milestone, Evidence, Activity, Disbursement, Contract, Award, Organisation, Programme, Portfolio, ApprovalStatus, ActivityStatus, ContractStatus, EvidenceType, Notification, NotificationStatus, NotificationPlatform, } from '../../types'; 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'; 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'; const EVIDENCE_TYPE_LABELS: Record = { [EvidenceType.AttendanceRegister]: 'Attendance Register / Progress Report', [EvidenceType.BankStatement]: 'Bank Statement / Financial Statement', [EvidenceType.CompletionCertificate]: 'Completion Certificate', [EvidenceType.FinancialInvoiceOrReceipt]: 'Invoice / Receipt', [EvidenceType.GeotaggedPhoto]: 'Geotagged Site Photograph', [EvidenceType.InspectionReport]: 'Inspection / Audit Report', [EvidenceType.MeetingMinutes]: 'Meeting Minutes', [EvidenceType.PayrollOrStipendRegister]: 'Payroll / Stipend Register', [EvidenceType.ProcurementDocument]: 'Procurement Document', [EvidenceType.TechnicalSpecificationOrDesign]: 'Deliverable Proof / Technical Design', [EvidenceType.VideoRecording]: 'Video Recording', [EvidenceType.Other]: 'Supporting Document', }; function formatShortDate(dateStr?: string | null): string { if (!dateStr) return 'TBD'; const d = new Date(dateStr); if (isNaN(d.getTime())) return 'TBD'; return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: '2-digit' }); } function formatFullDate(dateStr?: string | null): string { if (!dateStr) return 'Not set'; const d = new Date(dateStr); if (isNaN(d.getTime())) return 'Not set'; return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); } function calculateDaysLeft(endDateStr?: string | null): number { if (!endDateStr) return 90; const end = new Date(endDateStr).getTime(); if (isNaN(end)) return 90; const diff = Math.ceil((end - Date.now()) / (1000 * 60 * 60 * 24)); return Math.max(0, diff); } function getInitials(name?: string | null): string { if (!name) return 'PF'; const parts = name.trim().split(/\s+/); if (parts.length >= 2) { return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); } return name.slice(0, 2).toUpperCase(); } export function DashboardPage() { const { user, sub, logout } = useAuth(); const { canAdd } = useRbac(); const { success: showSuccessToast, error: showErrorToast, info: showInfoToast } = useToast(); const queryClient = useQueryClient(); const applicantUserId = sub || user?.id || ''; // Navigation & UI state const [activeTab, setActiveTab] = useState('dashboard'); const [searchQuery, setSearchQuery] = useState(''); const [sortBy, setSortBy] = useState('date_added'); const [showBellDropdown, setShowBellDropdown] = useState(false); const [showUserDropdown, setShowUserDropdown] = useState(false); const [showMobileMenu, setShowMobileMenu] = useState(false); // In-place expanded project state (Requirement #4: NO modal used for project details) const [expandedProjectIds, setExpandedProjectIds] = useState>({}); // Target for evidence upload modal (Requirements #8, #9, #10) const [uploadTarget, setUploadTarget] = useState<{ project: Project; milestone?: Milestone | null; activity: Activity; } | null>(null); // Messages / Contact modal state const [contactTarget, setContactTarget] = useState<{ organisation: Organisation | null; projectTitle?: string; recipientEmail?: string; recipientId?: string; } | null>(null); const [selectedNotification, setSelectedNotification] = useState(null); const [contractFile, setContractFile] = useState(null); const [signatureRef, setSignatureRef] = useState(''); const [isRefreshingAnalytics, setIsRefreshingAnalytics] = useState(false); // Contact / Message form state const [messageSubject, setMessageSubject] = useState(''); const [messageBody, setMessageBody] = useState(''); // In-App Notifications hook const { inboxNotifications, unreadCount, top5Unread, markAsRead, markAllAsRead, deleteMessage, sendMessage, isSending, } = useInAppNotifications(); // ============================================================================ // PRIMARY API CALL: GetIApplicantDataEndpoint (Requirement #2) // ============================================================================ const { data: applicantDataList = [], isLoading: isLoadingApplicantData, isFetching: isRefreshingApplicantData, refetch: refetchApplicantData, } = useQuery({ queryKey: ['partner-applicant-data', applicantUserId], queryFn: () => getApplicantData(applicantUserId), enabled: Boolean(applicantUserId), }); // Extract all unique projects, contracts, awards, and organisations from ApplicantData[] const partnerSummary = useMemo(() => { const projectsMap = new Map(); const contractsMap = new Map(); const awardsList: Award[] = []; let primaryInvitedOrg: Organisation | null = null; let primaryFundingOrg: Organisation | null = null; for (const record of applicantDataList) { if (record.invitedOrganisation && !primaryInvitedOrg) { primaryInvitedOrg = record.invitedOrganisation; } if (record.fundingOrganisation && !primaryFundingOrg) { primaryFundingOrg = record.fundingOrganisation; } const primaryAward = record.awards && record.awards.length > 0 ? record.awards[0] : null; if (record.awards) { for (const aw of record.awards) { if (!awardsList.some(x => String(x.id) === String(aw.id))) { awardsList.push(aw); } } } if (record.contract && record.contract.id) { contractsMap.set(String(record.contract.id), { contract: record.contract, award: primaryAward, fundingOrganisation: record.fundingOrganisation ?? null, invitedOrganisation: record.invitedOrganisation ?? null, }); } if (record.projects && record.projects.length > 0) { for (const proj of record.projects) { if (!proj || !proj.id) continue; const matchingAward = record.awards?.find(a => String(a.programmeId) === String(proj.programmeId)) || primaryAward; projectsMap.set(String(proj.id), { project: proj, award: matchingAward, contract: record.contract ?? null, fundingOrganisation: record.fundingOrganisation ?? null, invitedOrganisation: record.invitedOrganisation ?? null, }); } } } return { projectEntries: Array.from(projectsMap.values()), contractEntries: Array.from(contractsMap.values()), awardsList, primaryInvitedOrg, primaryFundingOrg, }; }, [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 = {}, isFetching: isRefreshingProjectDetails, refetch: refetchProjectDetails, } = useQuery< Record< string, { milestones: Milestone[]; evidence: Evidence[]; activities: Activity[]; disbursements: Disbursement[]; } > >({ queryKey: ['partner-project-details', projectIdsKey], queryFn: async () => { const entries = partnerSummary.projectEntries; if (entries.length === 0) return {}; let allDisbursements: Disbursement[] = []; try { allDisbursements = await getDisbursements(1, 100); } catch { allDisbursements = []; } const results = await Promise.all( entries.map(async ({ project }) => { const pid = project.id; const [milestones, evidence, activities] = await Promise.all([ getMilestones(pid).catch(() => [] as Milestone[]), getProjectEvidence(pid).catch(() => [] as Evidence[]), getActivities(pid).catch(() => [] as Activity[]), ]); const projectDisbursements = allDisbursements.filter( d => String(d.projectId) === String(pid) ); return [ String(pid), { milestones, evidence, activities, disbursements: projectDisbursements, }, ] as const; }) ); return Object.fromEntries(results); }, enabled: partnerSummary.projectEntries.length > 0, }); // Combine into EnrichedPartnerProject[] const enrichedProjects: EnrichedPartnerProject[] = useMemo(() => { return partnerSummary.projectEntries.map(entry => { const details = projectDetailsMap[String(entry.project.id)] || { milestones: [], evidence: [], activities: [], disbursements: [], }; return { ...entry, ...details, }; }); }, [partnerSummary.projectEntries, projectDetailsMap]); // Filter and sort projects const filteredProjects = useMemo(() => { const q = searchQuery.trim().toLowerCase(); const list = enrichedProjects.filter(item => { if (!q) return true; const pName = (item.project.name || '').toLowerCase(); const pDesc = (item.project.description || '').toLowerCase(); const fOrg = (item.fundingOrganisation?.name || '').toLowerCase(); return pName.includes(q) || pDesc.includes(q) || fOrg.includes(q); }); return list.sort((a, b) => { if (sortBy === 'name') { return (a.project.name || '').localeCompare(b.project.name || ''); } if (sortBy === 'grant_amount') { const valA = a.contract?.totalValue || a.award?.amount || 0; const valB = b.contract?.totalValue || b.award?.amount || 0; return Number(valB) - Number(valA); } if (sortBy === 'days_left') { const daysA = calculateDaysLeft(a.project.endedAt || a.contract?.expiresAt); const daysB = calculateDaysLeft(b.project.endedAt || b.contract?.expiresAt); return daysA - daysB; } // date_added default: latest created first const timeA = new Date(a.project.createdAt || 0).getTime(); const timeB = new Date(b.project.createdAt || 0).getTime(); return timeB - timeA; }); }, [enrichedProjects, searchQuery, sortBy]); // 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 ? Math.round((completedMilestones.length / allMilestones.length) * 100) : 100; const totalFunding = partnerSummary.contractEntries.reduce((sum, c) => sum + Number(c.contract.totalValue || 0), 0) || partnerSummary.awardsList.reduce((sum, a) => sum + Number(a.amount || 0), 0); const totalDisbursed = enrichedProjects .flatMap(p => p.disbursements) .filter(d => Number(d.status) === ApprovalStatus.Approved) .reduce((sum, d) => sum + Number(d.amount || 0), 0); 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); setExpandedProjectIds(prev => ({ ...prev, [key]: !prev[key], })); }; // Submit activity for approval by org staff (Requirements #10, #11) const handleSubmitActivityForApproval = async ( activity: Activity, milestone?: Milestone | null, evidences?: Evidence[] ) => { try { const pid = activity.projectId || 0; await submitActivityForApproval(activity, pid, evidences); await queryClient.invalidateQueries({ queryKey: ['partner-project-details'] }); showSuccessToast( 'Activity Submitted for Review', 'Activity status updated. Organisation staff will review uploaded evidence and consider formal approval.' ); } catch (err: any) { showErrorToast(err); } }; // Email resolution helpers for messages const getMessageSenderEmail = (notification: Notification | null): string => { if (!notification) { return ( partnerSummary.primaryFundingOrg?.email || (partnerSummary.primaryFundingOrg as any)?.Email || 'support@ifri.khongisa.co.za' ); } const dest = notification.destination || ''; let rawFrom = ''; if (dest.includes('From:')) { rawFrom = dest.split('From:')[1]?.split('|')[0]?.trim().replace(/[<>]/g, '') || ''; } else if (!dest.includes('To:') && dest.includes('@')) { rawFrom = dest.trim(); } if (rawFrom) { const resolved = getUserEmail(rawFrom); if (resolved && resolved.includes('@')) { return resolved; } if (rawFrom.includes('@')) { return rawFrom; } } return ( partnerSummary.primaryFundingOrg?.email || (partnerSummary.primaryFundingOrg as any)?.Email || 'support@ifri.khongisa.co.za' ); }; const getMessageRecipientEmail = (notification: Notification | null): string => { if (!notification) return (user?.email || (user as any)?.profile?.email || '') || (user as any)?.email || 'Partner User'; const dest = notification.destination || ''; let rawTo = ''; if (dest.includes('To:')) { rawTo = dest.split('To:')[1]?.split('|')[0]?.trim().replace(/[<>]/g, '') || ''; } if (rawTo) { const resolved = getUserEmail(rawTo); if (resolved && resolved.includes('@')) { return resolved; } if (rawTo.includes('@')) { return rawTo; } } if (notification.recipient) { const resolvedRecip = getUserEmail(notification.recipient); if (resolvedRecip && resolvedRecip.includes('@')) { return resolvedRecip; } } return (user?.email || (user as any)?.profile?.email || '') || (user as any)?.email || 'Partner User'; }; const handleReplyToMessage = async (notification: Notification) => { const senderEmail = getMessageSenderEmail(notification).trim().toLowerCase(); const currentUserEmail = ( (user?.email || (user as any)?.profile?.email || '') || (user as any)?.email || '' ).trim().toLowerCase(); if (currentUserEmail && senderEmail && senderEmail === currentUserEmail) { showErrorToast( 'Action Not Allowed', 'You cannot reply to a message sent from your own email address.' ); return; } const replySubject = notification.subject?.startsWith('Re:') ? notification.subject : `Re: ${notification.subject || 'Message'}`; // Requirement #1: Resolve sender's user ID from TenantUsers by email let senderUserId: string | undefined = undefined; if (senderEmail) { senderUserId = await resolveUserIdByEmail( senderEmail, notification.organisationId || partnerSummary.primaryFundingOrg?.id || 1 ); } setContactTarget({ organisation: partnerSummary.primaryFundingOrg, projectTitle: replySubject, recipientEmail: senderEmail, recipientId: senderUserId, }); setMessageSubject(replySubject); setMessageBody(''); setSelectedNotification(null); }; // Secure download link handler const handleOpenSecureFile = async (type: 'contract' | 'evidence', id: string | number) => { try { showInfoToast('Opening Document', 'Generating secure link...'); let url = ''; if (type === 'contract') { url = await getContractSecureLink(id); } else { url = await getEvidenceSecureLink(id); } if (url && typeof window !== 'undefined') { window.open(url, '_blank', 'noopener,noreferrer'); } else { showErrorToast('Failed to generate a download link for this file.'); } } catch (err: any) { showErrorToast(err); } }; // Contract execution mutation const signContractMutation = useMutation({ mutationFn: async (contract: Contract) => { if (contractFile) { await uploadContractFile(contract.id, contractFile); } await executeContract({ contractId: contract.id, externalSignatureId: signatureRef.trim() || `SIG-${Date.now()}`, signedDocumentUrl: contract.signedDocumentUrl || `s3://contracts/${contract.id}/signed.pdf`, }); }, onSuccess: () => { setContractFile(null); setSignatureRef(''); showSuccessToast('Agreement Executed', 'Contract signed and submitted.'); queryClient.invalidateQueries({ queryKey: ['partner-applicant-data'] }); }, onError: (err) => showErrorToast(err), }); // Contact form submission const handleSendContactMessage = async (e: React.FormEvent) => { e.preventDefault(); if (!messageSubject.trim() || !messageBody.trim()) { showErrorToast('Please provide both a subject and message.'); return; } const fundingEmail = partnerSummary.primaryFundingOrg?.email || (partnerSummary.primaryFundingOrg as any)?.Email || 'support@ifri.khongisa.co.za'; const targetEmail = contactTarget?.recipientEmail || fundingEmail; try { await sendMessage({ recipientEmail: targetEmail, recipient: targetEmail, subject: messageSubject.trim(), message: messageBody.trim(), organisationId: partnerSummary.primaryFundingOrg?.id || undefined, }); showSuccessToast('Message Sent', `Your communication was delivered to ${targetEmail}.`); setContactTarget(null); setMessageSubject(''); setMessageBody(''); } catch (err: any) { showErrorToast(err); } }; // Nav Tabs const navTabs: { id: NavTab; label: string }[] = [ { id: 'dashboard', label: 'Dashboard' }, { id: 'messages', label: 'Messages' }, { id: 'contracts', label: 'Awards & Agreements' }, { id: 'analytics', label: 'Analytics' }, { id: 'documents', label: 'Deliverables' }, ]; return (
{/* Ambient background glow from landing page */}
{/* ===================================================================== TOP NAVIGATION BAR — 72PX, FULL-WIDTH EDGE-TO-EDGE ===================================================================== */}
{/* Left: Brand Glyph & Horizontal Navigation Links */}
setActiveTab('dashboard')} title="Dashboard Home" > PFM Partner
{/* Nav Tabs */}
{/* Right: Search, Notifications Bell, User Menu */}
{/* Mobile Navigation Toggle Button (< md) */} {/* Search Input */}
setSearchQuery(e.target.value)} className="w-full bg-slate-900/80 border border-white/10 focus:border-emerald-500/50 rounded-xl pl-9 pr-3 py-1.5 text-xs text-slate-100 placeholder-slate-400 focus:outline-none transition-colors" /> {searchQuery && ( )}
{/* Refresh Projects Button */} {/* Notifications Bell */}
{/* Bell Dropdown */} {showBellDropdown && (
Notifications {unreadCount > 0 && ( )}
{top5Unread.length === 0 ? (
No unread notifications
) : ( top5Unread.map(item => (
{ markAsRead(item.id); setSelectedNotification(item); setShowBellDropdown(false); }} className="p-3 hover:bg-white/[0.03] transition-colors cursor-pointer text-left" >

{item.subject}

{stripMarkdown(item.message || '')}

)) )}
)}
{/* User Profile Menu */}
{showUserDropdown && (

{(user?.name || (user as any)?.profile?.name || '') || 'Partner User'}

{(user?.email || (user as any)?.profile?.email || '') || ''}

{partnerSummary.primaryInvitedOrg && (

{partnerSummary.primaryInvitedOrg.name}

)}
)}
{/* ===================================================================== MOBILE NAVIGATION DRAWER (< md) (Requirement #6) ===================================================================== */} {showMobileMenu && (
{navTabs.map(tab => { const isActive = activeTab === tab.id; return ( ); })}
)} {/* ===================================================================== MAIN DASHBOARD BODY (Max-width 1440px, full-width horizontal layout) (Overview & Inbox sidebar deleted per Requirements #1, #2, #3) ===================================================================== */}
{isLoadingApplicantData ? (

Loading your funding portfolio & projects...

) : activeTab === 'dashboard' ? ( /* =================================================================== DASHBOARD: FULL-WIDTH PROJECT LISTING (No Overview, No Inbox) =================================================================== */
{/* Top Bar: Title & Sort Options */}

Ongoing Projects

Click “View Details” on any project to inspect its milestones, track progress, and fulfill activity evidence requirements.

{/* Sort Selector */}
{/* Full-width Projects List */}
{filteredProjects.length > 0 ? ( filteredProjects.map((item) => ( handleToggleExpand(item.project.id)} onUploadEvidence={(target) => setUploadTarget(target)} onSubmitActivityForApproval={handleSubmitActivityForApproval} onOpenSecureFile={handleOpenSecureFile} /> )) ) : ( /* Empty State */

{searchQuery ? 'No Matching Projects Found' : 'No Active Projects Assigned Yet'}

{searchQuery ? `We couldn't find any ongoing projects matching "${searchQuery}". Clear your search query to see your full workspace portfolio.` : "Welcome to your Partner Workspace! Your funding organisation hasn't assigned any active project records to your profile just yet. If your funding award was recently approved or updated, click below to sync and retrieve your latest project schedule."}

{searchQuery && ( )}
)}
) : activeTab === 'messages' ? ( /* =================================================================== MESSAGES TAB =================================================================== */

Messages & Communications

Direct communication with your funding programme officers.

{inboxNotifications.length === 0 ? (
No new messages in your inbox yet. Messages sent by programme managers will be displayed here.
) : ( inboxNotifications.map(msg => { const isUnread = Number(msg.status) !== NotificationStatus.Read; return (
{ markAsRead(msg.id); setSelectedNotification(msg); }} className="p-5 hover:bg-white/[0.03] transition-colors flex items-start justify-between gap-4 cursor-pointer" >
{getInitials(msg.subject || 'PFM')}

{msg.subject || 'Programme Notification'}

{isUnread && ( New )}

{stripMarkdown(msg.message || '')}

From: {getMessageSenderEmail(msg)}
{formatFullDate(msg.createdAt)}
); }) )}
) : activeTab === 'contracts' ? ( /* =================================================================== CONTRACTS & APPROVED AWARDS TAB (Card per Award with Contract, Programme & Portfolio) (Requirement #2) =================================================================== */

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.

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

) : ( 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 (
{/* Top ambient glow */}
{/* 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.

)}
); }) )}
) : activeTab === 'analytics' ? ( /* =================================================================== ANALYTICS TAB (Real API Derived Portfolio Metrics with Refresh Button) (Requirement #1) =================================================================== */
{/* Header with Refresh Button */}

Portfolio Analytics

Live Performance

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 */}
{portfolioStats.activeProjects} Active

{portfolioStats.totalProjects}

Funded Projects in Execution

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

{/* Card 3: Capital Allocation & Drawdowns */}
{portfolioStats.disbursementRate}% Disbursed

{formatCompactZAR(portfolioStats.totalDisbursed)}

of {formatCompactZAR(portfolioStats.totalFunding)} Total Capital

{/* 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
); })}
)}
) : ( /* =================================================================== DOCUMENTS & DELIVERABLES TAB =================================================================== */

Deliverables & Evidence Documents

Invoices, reports, and verified deliverable proofs submitted across projects.

{enrichedProjects.flatMap(p => p.evidence.map(ev => ({ ev, project: p.project }))).length === 0 ? (

No documents uploaded yet. Expand any project on your Dashboard to upload proof files.

) : (
{enrichedProjects .flatMap(p => p.evidence.map(ev => ({ ev, project: p.project }))) .map(({ ev, project }) => { const evType = Number((ev as any).evidenceType ?? ev.type); const evDate = (ev as any).submittedAt ?? ev.createdAt; return (

{EVIDENCE_TYPE_LABELS[evType] || 'Supporting Document'}{' '} (v{ev.version || 1})

Project: {project.name} • Submitted {formatFullDate(evDate)}

); })}
)}
)}
{/* ===================================================================== EVIDENCE UPLOAD MODAL (Supports Multiple Uploads per Activity) (Requirements #8, #9, #10) ===================================================================== */} {uploadTarget && ( setUploadTarget(null)} project={uploadTarget.project} milestone={uploadTarget.milestone} activity={uploadTarget.activity} onEvidenceUploaded={() => { queryClient.invalidateQueries({ queryKey: ['partner-project-details'] }); showSuccessToast( 'Evidence Uploaded', 'Supporting document uploaded and linked to activity.' ); setUploadTarget(null); }} /> )} {/* ===================================================================== SEND MESSAGE MODAL (WYSIWYG Composer, strictly In-App, centered to window) (Requirements #1, #2, #8) ===================================================================== */} {contactTarget && ( setContactTarget(null)} recipientEmail={ contactTarget.recipientEmail || partnerSummary.primaryFundingOrg?.email || 'support@ifri.khongisa.co.za' } organisationName={ contactTarget.organisation?.name || partnerSummary.primaryFundingOrg?.name || 'Programme Management Office' } initialSubject={ messageSubject || (contactTarget.projectTitle?.startsWith('Re:') ? contactTarget.projectTitle : '') } isSending={isSending} onSend={async (subj, markdown) => { const fundingEmail = partnerSummary.primaryFundingOrg?.email || (partnerSummary.primaryFundingOrg as any)?.Email || 'support@ifri.khongisa.co.za'; const targetEmail = contactTarget.recipientEmail || fundingEmail; await sendMessage({ recipientEmail: targetEmail, recipient: targetEmail, recipientId: contactTarget.recipientId, subject: subj, message: markdown, platform: NotificationPlatform.InApp, organisationId: contactTarget.organisation?.id || partnerSummary.primaryFundingOrg?.id || 1, }); showSuccessToast('Message Sent', `Your communication was delivered to ${targetEmail}.`); setContactTarget(null); setMessageSubject(''); setMessageBody(''); }} /> )} {/* ===================================================================== MESSAGE READER MODAL (Centered to Window via Portal) (Requirements #1, #2, #3, #4, #8) ===================================================================== */} {selectedNotification && typeof document !== 'undefined' && createPortal(
{/* Header */}

{selectedNotification.subject || 'Message Details'}

From:{' '} {getMessageSenderEmail(selectedNotification)}
To:{' '} {getMessageRecipientEmail(selectedNotification)}
{formatFullDate(selectedNotification.createdAt)}
{/* Message Content Viewer */}
{/* Action Buttons: Reply, Delete, and Close */}
, document.body )}
); }