import React, { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Search, 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, } 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, } from '../../services/api'; import { ApplicantData, Project, Milestone, Evidence, Activity, Disbursement, Contract, Award, Organisation, ApprovalStatus, ActivityStatus, ContractStatus, EvidenceType, Notification, NotificationStatus, } from '../../types'; import { formatZAR, formatCompactZAR } from '../../lib/formatters'; import { MessageViewer } from '../notifications/MessageViewer'; import { stripMarkdown } from '../../lib/markdownUtils'; import { ProjectCard, EnrichedPartnerProject } from './ProjectCard'; import { EvidenceUploadModal } from './EvidenceUploadModal'; 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); // 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(''); // 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 (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]); const projectIdsKey = useMemo( () => partnerSummary.projectEntries.map(e => String(e.project.id)).join(','), [partnerSummary.projectEntries] ); // Downstream queries: Milestones, Evidence, Activities, Disbursements 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, }); // 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 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 totalEvidenceCount = enrichedProjects.flatMap(p => p.evidence).length; return { totalProjects, totalMilestones: allMilestones.length, completedMilestones: completedMilestones.length, milestoneRate, totalFunding, totalDisbursed, totalEvidenceCount, }; }, [enrichedProjects, partnerSummary]); // 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); } }; // 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 recipient = contactTarget?.recipientId || contactTarget?.recipientEmail || contactTarget?.organisation?.name || applicantUserId; try { await sendMessage({ recipient, subject: messageSubject.trim(), message: messageBody.trim(), }); showSuccessToast('Message Sent', 'Your communication was delivered to the programme team.'); 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 (
{/* ===================================================================== TOP NAVIGATION BAR — 72PX, FULL-WIDTH EDGE-TO-EDGE ===================================================================== */}
{/* Left: Brand Glyph & Horizontal Navigation Links */}
setActiveTab('dashboard')} title="Dashboard Home" >
{/* Nav Tabs */}
{/* Right: Search, Notifications Bell, User Menu */}
{/* Search Input */}
setSearchQuery(e.target.value)} className="w-full bg-[#181622] border border-white/[0.06] focus:border-[#8B5CF6]/50 rounded-xl pl-9 pr-3 py-1.5 text-xs text-[#F1F1F6] placeholder-[#9A95B0] 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?.profile?.name || 'Partner User'}

{user?.profile?.email || ''}

{partnerSummary.primaryInvitedOrg && (

{partnerSummary.primaryInvitedOrg.name}

)}
)}
{/* ===================================================================== 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 || '')}

{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 (Zero Mock Data: Real API Derived Values) =================================================================== */

Portfolio Analytics

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

{/* Card 1: Milestone Fulfillment Rate */}
{portfolioStats.completedMilestones} of {portfolioStats.totalMilestones} Milestones

{portfolioStats.milestoneRate}%

Milestone Completion Rate

{/* Card 2: Active Projects */}
Active

{portfolioStats.totalProjects} Projects

Assigned Funded Projects

{/* Card 3: Total Supporting Proofs */}
Audited

{portfolioStats.totalEvidenceCount} Documents

Uploaded Evidence Deliverables

) : ( /* =================================================================== 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 ===================================================================== */} {contactTarget && (

Send Message

setMessageSubject(e.target.value)} placeholder="How can the funding team help you?" className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs text-white focus:outline-none focus:border-indigo-400" />