import React, { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Search, Bell, LogOut, Check, Calendar, DollarSign, Paperclip, Hourglass, MapPin, Clock, Star, FileText, Upload, Send, ChevronDown, ShieldCheck, MessageSquare, FileCheck2, X, Plus, ExternalLink, CheckCircle2, RefreshCw, Briefcase, TrendingUp, Award as AwardIcon, Info, Sparkles, Infinity as InfinityIcon, } 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, uploadEvidenceFile, createActivity, createDisbursement, createMilestone, uploadContractFile, executeContract, getContractSecureLink, getEvidenceSecureLink, } from '../../services/api'; import { ApplicantData, Project, Milestone, Evidence, Activity, Disbursement, Contract, Award, Organisation, ApprovalStatus, ActivityStatus, ContractStatus, EvidenceType, Notification, NotificationPlatform, NotificationStatus, } from '../../types'; import { formatZAR, formatCompactZAR } from '../../lib/formatters'; import { MessageViewer } from '../notifications/MessageViewer'; import { stripMarkdown } from '../../lib/markdownUtils'; interface EnrichedPartnerProject { project: Project; award: Award | null; contract: Contract | null; fundingOrganisation: Organisation | null; invitedOrganisation: Organisation | null; milestones: Milestone[]; evidence: Evidence[]; activities: Activity[]; disbursements: Disbursement[]; } 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(); } // ----------------------------------------------------------------------------- // SHOWCASE INBOX DATA FOR DASHBOARD PREVIEW // ----------------------------------------------------------------------------- interface ShowcaseInboxItem { id: string; name: string; snippet: string; unread: boolean; avatarBg: string; avatarInitials: string; } const SHOWCASE_INBOX: ShowcaseInboxItem[] = [ { id: 'inbox-1', name: 'Mustafa Omar', snippet: 'Lorem ipsum dolor sit amet, consectetur ..', unread: true, avatarBg: 'bg-gradient-to-tr from-blue-500 to-indigo-600', avatarInitials: 'MO', }, { id: 'inbox-2', name: 'Annie Spratt', snippet: 'Lorem ipsum dolor sit amet, conseetur adipi..', unread: false, avatarBg: 'bg-gradient-to-tr from-cyan-400 to-teal-500', avatarInitials: 'AS', }, { id: 'inbox-3', name: 'Elie Peragini', snippet: 'Lorem ipsum dolor amet, consectetur ornare..', unread: false, avatarBg: 'bg-gradient-to-tr from-purple-500 to-pink-500', avatarInitials: 'EP', }, { id: 'inbox-4', name: 'Tim Stundnt', snippet: 'Lorem ipsum dolor Mauris, sem commodo ...', unread: true, avatarBg: 'bg-gradient-to-tr from-amber-500 to-emerald-600', avatarInitials: 'TS', }, { id: 'inbox-5', name: 'Kerr Lieu', snippet: 'Lorem ipsum dolor sit amet, consectetur adipi..', unread: false, avatarBg: 'bg-gradient-to-tr from-orange-400 to-rose-500', avatarInitials: 'KL', }, { id: 'inbox-6', name: 'Ozan Safak', snippet: 'Lorem ipsum dolor sit amet, consectetur onre..', unread: false, avatarBg: 'bg-gradient-to-tr from-slate-600 to-slate-800', avatarInitials: 'OS', }, { id: 'inbox-7', name: 'Katrine Mareu', snippet: 'Lorem ipsum dolor sit amet, consectetur adipi..', unread: false, avatarBg: 'bg-gradient-to-tr from-sky-400 to-blue-600', avatarInitials: 'KM', }, { id: 'inbox-8', name: 'Deao Saienm', snippet: 'Lorem ipsum dolor sit amet, consectetur onre..', unread: true, avatarBg: 'bg-gradient-to-tr from-amber-600 to-orange-600', avatarInitials: 'DS', }, { id: 'inbox-9', name: 'Lily Hagon', snippet: 'Lorem ipsum dolor sit amet, consectetur onre..', unread: false, avatarBg: 'bg-gradient-to-tr from-rose-400 to-pink-600', avatarInitials: 'LH', }, ]; 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); // Modals state const [selectedProjectItem, setSelectedProjectItem] = useState(null); const [projectModalTab, setProjectModalTab] = useState<'milestones' | 'evidence' | 'payments' | 'activities' | 'contract'>('milestones'); const [contactTarget, setContactTarget] = useState<{ organisation: Organisation | null; projectTitle?: string; recipientEmail?: string; recipientId?: string; } | null>(null); const [selectedNotification, setSelectedNotification] = useState(null); // Forms inside Project Details Modal const [uploadingEvidence, setUploadingEvidence] = useState(false); const [selectedMilestoneForUpload, setSelectedMilestoneForUpload] = useState(''); const [selectedEvidenceType, setSelectedEvidenceType] = useState(EvidenceType.FinancialInvoiceOrReceipt); const [evidenceFile, setEvidenceFile] = useState(null); const [paymentAmount, setPaymentAmount] = useState(''); const [paymentMilestoneId, setPaymentMilestoneId] = useState(''); const [activityTitle, setActivityTitle] = useState(''); const [activityCategory] = useState('Implementation'); const [activityAmount, setActivityAmount] = useState(''); const [activityDescription, setActivityDescription] = useState(''); const [newMilestoneName, setNewMilestoneName] = useState(''); const [newMilestoneDueAt, setNewMilestoneDueAt] = useState(''); const [contractFile, setContractFile] = useState(null); const [signatureRef, setSignatureRef] = useState(''); // Contact / Message form state const [messageSubject, setMessageSubject] = useState(''); const [messageBody, setMessageBody] = useState(''); // In-App Notifications hook const { inboxNotifications, unreadCount, top5Unread, markAsRead, markAllAsRead, sendMessage, isSending, } = useInAppNotifications(); // ============================================================================ // PRIMARY API CALL: GetIApplicantDataEndpoint // ============================================================================ const { data: applicantDataList = [], isLoading: isLoadingApplicantData, isFetching: isRefreshingApplicantData, refetch: refetchApplicantData, } = useQuery({ queryKey: ['partner-applicant-data', applicantUserId], queryFn: () => getApplicantData(applicantUserId), enabled: Boolean(applicantUserId), staleTime: 30_000, }); // 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]); const projectIdsKey = useMemo( () => partnerSummary.projectEntries.map(e => String(e.project.id)).join(','), [partnerSummary.projectEntries] ); // Downstream queries const { data: projectDetailsMap = {} } = 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, staleTime: 30_000, }); // 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 titleMatch = (item.project.name || '').toLowerCase().includes(q); const descMatch = (item.project.description || '').toLowerCase().includes(q); const funderMatch = (item.fundingOrganisation?.name || '').toLowerCase().includes(q); return titleMatch || descMatch || funderMatch; }); 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 valB - valA; } if (sortBy === 'days_left') { return ( calculateDaysLeft(a.project.endedAt || a.contract?.expiresAt) - calculateDaysLeft(b.project.endedAt || b.contract?.expiresAt) ); } const timeA = new Date(a.project.createdAt || 0).getTime(); const timeB = new Date(b.project.createdAt || 0).getTime(); return timeB - timeA; }); }, [enrichedProjects, searchQuery, sortBy]); // Calculate Overview 2x2 metrics const overviewMetrics = useMemo(() => { let runningProjects = 0; let completedProjects = 0; let pendingPayment = 0; let totalPayment = 0; for (const item of enrichedProjects) { const st = Number(item.project.status); const allMilestonesVerified = item.milestones.length > 0 && item.milestones.every(m => Number(m.status) === ApprovalStatus.Approved); if (st === ActivityStatus.Inactive || allMilestonesVerified) { completedProjects += 1; } else { runningProjects += 1; } for (const d of item.disbursements) { const dStatus = Number(d.status); if (dStatus === ApprovalStatus.Approved) { totalPayment += Number(d.amount || 0); } else if ( dStatus === ApprovalStatus.Pending || dStatus === ApprovalStatus.Review ) { pendingPayment += Number(d.amount || 0); } } } if (totalPayment === 0 && pendingPayment === 0 && enrichedProjects.length > 0) { const totalAwarded = partnerSummary.awardsList.reduce( (sum, a) => sum + Number(a.amount || 0), 0 ); const totalContracted = partnerSummary.contractEntries.reduce( (sum, c) => sum + Number(c.contract.totalValue || 0), 0 ); totalPayment = totalContracted || totalAwarded; } return { runningProjects, completedProjects, pendingPayment: pendingPayment > 0 ? formatZAR(pendingPayment) : '$0', totalPayment: totalPayment > 0 ? formatZAR(totalPayment) : '$0', }; }, [enrichedProjects, partnerSummary]); const activeProjectModalItem = useMemo(() => { if (!selectedProjectItem) return null; return ( enrichedProjects.find( x => String(x.project.id) === String(selectedProjectItem.project.id) ) || selectedProjectItem ); }, [selectedProjectItem, enrichedProjects]); // ============================================================================ // MUTATIONS // ============================================================================ const uploadEvidenceMutation = useMutation({ mutationFn: async () => { if (!activeProjectModalItem || !evidenceFile) { throw new Error('Please choose a file to upload.'); } setUploadingEvidence(true); const formData = new FormData(); formData.append('file', evidenceFile); formData.append('projectId', String(activeProjectModalItem.project.id)); if (selectedMilestoneForUpload) { formData.append('milestoneId', selectedMilestoneForUpload); } formData.append('evidenceType', String(selectedEvidenceType)); return await uploadEvidenceFile(formData); }, onSuccess: () => { setUploadingEvidence(false); setEvidenceFile(null); showSuccessToast('Document Submitted', 'Document uploaded and linked to your project.'); queryClient.invalidateQueries({ queryKey: ['partner-project-details'] }); }, onError: (err) => { setUploadingEvidence(false); showErrorToast(err); }, }); const requestPaymentMutation = useMutation({ mutationFn: async () => { if (!activeProjectModalItem) throw new Error('No project selected.'); const amt = Number(paymentAmount); if (!amt || amt <= 0) throw new Error('Please enter a valid payment amount greater than zero.'); return await createDisbursement({ projectId: activeProjectModalItem.project.id, milestoneId: paymentMilestoneId ? paymentMilestoneId : null, amount: amt, }); }, onSuccess: () => { setPaymentAmount(''); setPaymentMilestoneId(''); showSuccessToast('Payment Requested', 'Your payment request has been submitted for review.'); queryClient.invalidateQueries({ queryKey: ['partner-project-details'] }); }, onError: (err) => showErrorToast(err), }); const logActivityMutation = useMutation({ mutationFn: async () => { if (!activeProjectModalItem) throw new Error('No project selected.'); if (!activityTitle.trim()) throw new Error('Please enter a short title for this update.'); return await createActivity({ projectId: activeProjectModalItem.project.id, category: activityCategory, amount: Number(activityAmount) || 0, title: activityTitle.trim(), description: activityDescription.trim(), performedAt: new Date().toISOString(), }); }, onSuccess: () => { setActivityTitle(''); setActivityAmount(''); setActivityDescription(''); showSuccessToast('Update Saved', 'Project progress update logged.'); queryClient.invalidateQueries({ queryKey: ['partner-project-details'] }); }, onError: (err) => showErrorToast(err), }); const addMilestoneMutation = useMutation({ mutationFn: async () => { if (!activeProjectModalItem) throw new Error('No project selected.'); if (!newMilestoneName.trim() || !newMilestoneDueAt) { throw new Error('Please provide both a milestone name and due date.'); } return await createMilestone({ projectId: activeProjectModalItem.project.id, name: newMilestoneName.trim(), dueAt: newMilestoneDueAt, priority: 1, }); }, onSuccess: () => { setNewMilestoneName(''); setNewMilestoneDueAt(''); showSuccessToast('Milestone Added', 'Milestone added to your project schedule.'); queryClient.invalidateQueries({ queryKey: ['partner-project-details'] }); }, onError: (err) => showErrorToast(err), }); 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), }); const handleOpenSecureFile = async (type: 'contract' | 'evidence', id: string | number) => { try { const link = type === 'contract' ? await getContractSecureLink(id) : await getEvidenceSecureLink(id); if (link) { window.open(link, '_blank', 'noopener,noreferrer'); } else { showInfoToast('File Unavailable', 'No file URL is attached to this record yet.'); } } catch (err) { showErrorToast(err); } }; const handleSendContactMessage = async (e: React.FormEvent) => { e.preventDefault(); if (!messageSubject.trim() || !messageBody.trim()) { showInfoToast('Missing Fields', 'Please enter both a subject and message.'); return; } try { const targetOrg = contactTarget?.organisation || partnerSummary.primaryFundingOrg; const recipientEmail = contactTarget?.recipientEmail || targetOrg?.email || 'support@pfm.gov.za'; const recipientId = contactTarget?.recipientId || targetOrg?.userId || applicantUserId; await sendMessage({ recipientId: String(recipientId), recipientEmail, recipientName: targetOrg?.name || 'Funding Administrator', subject: messageSubject.trim(), message: messageBody.trim(), platform: NotificationPlatform.InApp, organisationId: targetOrg?.id || 1, }); showSuccessToast('Message Sent', 'Your message has been sent to the funding team.'); setMessageSubject(''); setMessageBody(''); setContactTarget(null); } catch (err) { showErrorToast(err); } }; const openContactModalForProject = (item: EnrichedPartnerProject) => { setContactTarget({ organisation: item.fundingOrganisation || partnerSummary.primaryFundingOrg, projectTitle: item.project.name || 'Grant Project', recipientEmail: item.fundingOrganisation?.email || undefined, recipientId: item.fundingOrganisation?.userId || undefined, }); setMessageSubject(`Enquiry regarding ${item.project.name || 'Project'}`); setMessageBody(''); }; // Nav Tabs const navTabs: { id: NavTab; label: string }[] = [ { id: 'dashboard', label: 'Dashboard' }, { id: 'messages', label: 'Messages' }, { id: 'contracts', label: 'Awards' }, { id: 'analytics', label: 'Analytics' }, { id: 'documents', label: 'Earnings' }, ]; // Combined Inbox items: real unread notifications first, followed by showcase mock items const combinedInbox = useMemo(() => { const list: ShowcaseInboxItem[] = []; if (inboxNotifications && inboxNotifications.length > 0) { for (const n of inboxNotifications.slice(0, 9)) { const isUnread = Number(n.status) !== NotificationStatus.Read; const sender = n.destination?.split('|')[0]?.replace('To:', '').trim() || partnerSummary.primaryFundingOrg?.name || 'Programme Officer'; list.push({ id: String(n.id), name: sender, snippet: stripMarkdown(n.message || n.subject || 'New update received'), unread: isUnread, avatarBg: 'bg-gradient-to-tr from-emerald-500 to-teal-600', avatarInitials: getInitials(sender), }); } } // Fill with showcase items to maintain layout integrity for (const item of SHOWCASE_INBOX) { if (!list.some(x => x.name.toLowerCase() === item.name.toLowerCase())) { list.push(item); } if (list.length >= 9) break; } return list; }, [inboxNotifications, partnerSummary]); return (
{/* ===================================================================== TOP NAVIGATION BAR — SNAPPED TO TOP, 72PX, FULL-WIDTH EDGE-TO-EDGE ===================================================================== */}
{/* Left: Brand Glyph & Horizontal Navigation Links */}
{/* Flat black infinity icon rotated with top lobe pointing to 2 o'clock */}
setActiveTab('dashboard')} title="Dashboard Home" >
{/* Nav Tabs with bottom indicator on active */}
{/* Right: Search Pill + Refresh + Notification Bell + Profile */}
{/* Search Input: 280px x 38px */}
setSearchQuery(e.target.value)} placeholder="Search ...." className="w-full h-full pl-9 pr-8 rounded-[8px] bg-white/[0.05] border border-white/[0.08] text-xs text-[#F1F1F6] placeholder:text-[#9A95B0]/70 focus:outline-none focus:border-[#8B5CF6] transition-all" /> {searchQuery && ( )}
{/* Refresh Data Button */} {/* Notification Bell */}
{showBellDropdown && (
Notifications {unreadCount > 0 && ( {unreadCount} unread )}
{unreadCount > 0 && ( )}
{top5Unread.length === 0 ? (
You are all caught up! No unread notifications.
) : ( top5Unread.map(item => ( )) )}
)}
{/* Profile Avatar + Name + Dropdown */}
{showUserDropdown && (

{user?.name || 'Samia Afrin Mithila'}

{user?.email || 'partner@grantee.org'}

{partnerSummary.primaryInvitedOrg && (

{partnerSummary.primaryInvitedOrg.name}

)}
)}
{/* Mobile Navigation bar for smaller screens */}
{navTabs.map(tab => ( ))}
{/* ===================================================================== MAIN BODY SECTION (max-w-[1440px] centered with outer ambient margin) ===================================================================== */}
{isLoadingApplicantData ? (

Loading Workspace...

Retrieving ongoing projects and milestones.

) : activeTab === 'dashboard' ? ( /* ================================================================= DASHBOARD: ASYMMETRICAL 2-COLUMN GRID (1fr 340px, gap 24px) ================================================================= */
{/* =============================================================== LEFT MAIN SECTION: ONGOING PROJECTS (~70% width) =============================================================== */}
{/* Title + Sort Row: 20px / 28px / font-weight 700 / #F1F1F6 */}

Ongoing Projects

{/* Sort selector matching "Sort by date added v" */}
{/* Stacked Project Cards */}
{filteredProjects.length > 0 ? ( filteredProjects.map((item, pIdx) => { const { project, award, contract, fundingOrganisation, milestones, evidence, disbursements } = item; const grantAmount = contract?.totalValue || award?.amount || 0; const disbursedAmount = disbursements .filter(d => Number(d.status) === ApprovalStatus.Approved) .reduce((sum, d) => sum + Number(d.amount || 0), 0); const daysLeft = calculateDaysLeft(project.endedAt || contract?.expiresAt); const docsSubmitted = evidence.length; const sortedMilestones = [...milestones].sort( (a, b) => new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime() ); const stepperNodes = [ { id: 'start', label: 'Project start', date: formatShortDate(project.startedAt || contract?.effectiveAt || project.createdAt), type: 'done' as const, }, ...[0, 1, 2, 3].map(idx => { const m = sortedMilestones[idx]; if (!m) { return { id: `m-placeholder-${idx}`, label: `Milestone ${idx + 1}`, date: 'Planned', type: 'upcoming' as const, }; } const mStatus = Number(m.status); const isDone = mStatus === ApprovalStatus.Approved; const isCurrent = !isDone && (mStatus === ApprovalStatus.Review || idx === 0 || Number(sortedMilestones[idx - 1]?.status) === ApprovalStatus.Approved); return { id: String(m.id), label: m.name || `Milestone ${idx + 1}`, date: formatShortDate(m.dueAt), type: isDone ? ('done' as const) : isCurrent ? ('current' as const) : ('upcoming' as const), }; }), ]; const leadName = fundingOrganisation?.name || partnerSummary.primaryFundingOrg?.name || 'Project Officer'; const leadRole = 'Programme Manager'; const avatarColors = [ 'bg-gradient-to-tr from-sky-400 to-blue-600', 'bg-gradient-to-tr from-amber-400 to-[#F97316]', 'bg-gradient-to-tr from-[#8B5CF6] to-indigo-600', ]; return (
{/* Left Sub-portion (~70%): Title, Description, 4 Metrics, Horizontal Timeline */}

{project.name || 'Funded Project'}

{project.description || 'Funding award execution, deliverable submissions, and post-fund milestone tracking.'}

{/* 4 Metrics in a horizontal inline row: 13px font-weight 500 #D1CBDD */}
{formatCompactZAR(grantAmount)} Award
{formatCompactZAR(disbursedAmount)} Spend
{daysLeft} Days left
{docsSubmitted} Docs submitted
{/* Horizontal Timeline Stepper: height 2px, 10px nodes */}
{stepperNodes.map((step, sIdx) => { const isLast = sIdx === stepperNodes.length - 1; const isDotted = sIdx === 2; // Exact connector style from reference return (
{step.type === 'done' ? (
) : step.type === 'current' ? (
) : (
)} {!isLast && (
)}

{step.label}

{step.date}

); })}
{/* Right Sub-portion (~30% / 220px): Contact Lead, 3 Meta Rows, 2 Stacked Action Buttons */}
{/* Contact Person */}

{leadName}

{leadRole}

{getInitials(leadName)}
{/* 3 Meta Rows: Location, Time, Star Rating */}
Gauteng, South Africa
01:23 pm
4.8/5
{/* 2 Stacked Buttons: Contact (Outline) & View Details (Soft Tinted) */}
); }) ) : ( /* Friendly Empty State with Refresh Button & Informative Section */
{/* Ambient background glow */}
{/* Friendly Icon Badge */}
{/* Friendly Heading: 20px font-bold #F1F1F6 */}

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

{/* Friendly Message: 14px / 22px / #9A95B0 */}

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

{/* Refresh / Action Button */}
{searchQuery ? ( ) : null}
{/* Elegantly placed informative text below all that */}
How Project Activation Works
Post-Fund Lifecycle
1

Award Handover & Formal Agreement

Following sanction by the approval committee, your funding organisation registers the award. A legally binding grant agreement is automatically drafted for your review and digital execution.

2

Milestones & Evidence Submission

Once the project is active, your work breakdown structure, deliverable due dates, and mandatory evidence requirements appear here so you can easily upload progress reports, invoices, and certificates.

3

Payment Requests & Controlled Tranches

Funds are released safely against verified milestone deliverables and satisfied conditions precedent. You can initiate drawdown requests directly from your project workspace.

Need status clarification? Contact your assigned Programme Officer.
)}
{/* =============================================================== RIGHT COLUMN: SIDEBAR WIDGETS (Overview & Inbox, 340px) =============================================================== */}
{/* 1. OVERVIEW CARD (2x2 Grid with vertical divider) */}

Overview

{/* Subtle vertical divider between the 2 columns */}
{/* Left Column: Running Project & Completed Project */}

{overviewMetrics.runningProjects}

Running Project

{overviewMetrics.completedProjects}

Completed Project

{/* Right Column: Have to Pay & Total Payment */}

{overviewMetrics.pendingPayment}

Have to Pay

{overviewMetrics.totalPayment}

Total Payment

{/* 2. INBOX CARD (Matching Dribbble design: Inbox + badge, See All, and clean avatar list) */}

Inbox

{unreadCount > 0 ? unreadCount : 3}
{/* List of Contacts & Messages: Compact vertical list */}
{combinedInbox.map((item) => (
{ const realMsg = inboxNotifications.find(x => String(x.id) === item.id); if (realMsg) { markAsRead(realMsg.id); setSelectedNotification(realMsg); } else { // Demo item: open contact popup to reply setContactTarget({ organisation: partnerSummary.primaryFundingOrg, projectTitle: item.name, }); setMessageSubject(`Re: Message from ${item.name}`); setMessageBody(''); } }} className="flex items-center gap-3 py-2 px-1 hover:bg-white/[0.02] rounded-xl transition-colors cursor-pointer" >
{item.avatarInitials}
{item.name} {item.unread && ( )}

{item.snippet}

))}
) : 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)}

{formatFullDate(msg.createdAt)}
); }) )}
) : activeTab === 'contracts' ? ( /* ================================================================= CONTRACTS & AWARDS TAB ================================================================= */

Contracts & Approved Awards

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

{partnerSummary.contractEntries.length === 0 ? (
No contracts have been issued to your organisation yet.
) : ( partnerSummary.contractEntries.map(({ contract, award, fundingOrganisation }) => { const isExecuted = Number(contract.status) === ContractStatus.Executed; const contractVer = (contract as any).version || 1; 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)}

{!isExecuted && canAdd('grant_financials') && ( )}
); }) )}
) : activeTab === 'analytics' ? ( /* ================================================================= ANALYTICS TAB ================================================================= */

Portfolio Analytics

Performance metrics, milestone velocity, and financial drawdown burn rates.

+18% vs target

92.4%

Milestone Completion Rate

On Track

{overviewMetrics.runningProjects} Active

Funded Projects in Execution

Audited

100%

Compliance & Evidence Health

) : ( /* ================================================================= DOCUMENTS & EARNINGS TAB ================================================================= */

Deliverables & Evidence Documents

Invoices, payment requests, and verified deliverable proofs submitted across projects.

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

No documents uploaded yet. Open any project on your Dashboard and click "View Details" to submit 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)}

); })}
)}
)}
{/* ===================================================================== MODAL 1: PROJECT WORKSPACE (From "View Details" Button) ===================================================================== */} {activeProjectModalItem && (
Project Workspace Funder: {activeProjectModalItem.fundingOrganisation?.name || 'Funding Partner'}

{activeProjectModalItem.project.name}

{activeProjectModalItem.project.description && (

{activeProjectModalItem.project.description}

)}
{( [ { id: 'milestones', label: `1. Milestones (${activeProjectModalItem.milestones.length})` }, { id: 'evidence', label: `2. Upload Proof (${activeProjectModalItem.evidence.length})` }, { id: 'payments', label: `3. Request Payment (${activeProjectModalItem.disbursements.length})` }, { id: 'activities', label: `4. Progress Updates (${activeProjectModalItem.activities.length})` }, { id: 'contract', label: '5. Grant Agreement' }, ] as const ).map(t => ( ))}
{projectModalTab === 'milestones' && (

Project Milestones

{activeProjectModalItem.milestones.length === 0 ? (

No milestones have been added to this project yet. You can add one below.

) : (
{activeProjectModalItem.milestones.map((m, idx) => { const isVerified = Number(m.status) === ApprovalStatus.Approved; return (
{isVerified ? : idx + 1}

{m.name}

Due: {formatFullDate(m.dueAt)}

{isVerified ? 'Verified' : 'In Progress'}
); })}
)}
{canAdd('projects_milestones') && (

Add a New Milestone

setNewMilestoneName(e.target.value)} placeholder="Milestone title (e.g. Phase 1 Delivery)" className="sm:col-span-2 px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white" /> setNewMilestoneDueAt(e.target.value)} className="px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white" />
)}
)} {projectModalTab === 'evidence' && (

Upload Supporting Document or Proof

Attach invoices, progress reports, photos, or certificates to prove milestone completion.

setEvidenceFile(e.target.files?.[0] || null)} className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-slate-300 file:mr-3 file:py-1 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-indigo-500/20 file:text-indigo-300" />
)} {projectModalTab === 'payments' && (

Request a Grant Payment Tranche

setPaymentAmount(e.target.value)} placeholder="e.g. 150000" className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white" />
)} {projectModalTab === 'activities' && (

Log Project Activity Update

setActivityTitle(e.target.value)} placeholder="Activity title" className="px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white" /> setActivityAmount(e.target.value)} placeholder="Cost incurred (optional)" className="px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white" />