From fddf400ddd3625ded71ff212930882785f7e8834 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 25 Oct 2026 18:30:32 +0200 Subject: [PATCH] Updated partner project listing to use returned data --- .../components/dashboard/DashboardPage.tsx | 2132 +++++------------ .../dashboard/EvidenceUploadModal.tsx | 108 +- .../src/components/dashboard/ProjectCard.tsx | 1003 ++++---- .../src/services/api/index.ts | 41 + 4 files changed, 1254 insertions(+), 2030 deletions(-) diff --git a/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx b/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx index 0fc736c..be2f497 100644 --- a/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx +++ b/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx @@ -4,21 +4,17 @@ import { Search, Bell, LogOut, - Check, Calendar, DollarSign, Paperclip, Hourglass, - MapPin, Clock, - Star, FileText, Upload, Send, ChevronDown, ShieldCheck, MessageSquare, - FileCheck2, X, Plus, ExternalLink, @@ -41,14 +37,11 @@ import { getProjectEvidence, getActivities, getDisbursements, - uploadEvidenceFile, - createActivity, - createDisbursement, - createMilestone, uploadContractFile, executeContract, getContractSecureLink, getEvidenceSecureLink, + submitActivityForApproval, } from '../../services/api'; import { ApplicantData, @@ -65,24 +58,13 @@ import { 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[]; -} +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'; @@ -133,93 +115,6 @@ function getInitials(name?: string | null): string { 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(); @@ -235,9 +130,17 @@ export function DashboardPage() { 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'); + // 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; @@ -246,23 +149,6 @@ export function DashboardPage() { } | 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(''); @@ -282,7 +168,7 @@ export function DashboardPage() { } = useInAppNotifications(); // ============================================================================ - // PRIMARY API CALL: GetIApplicantDataEndpoint + // PRIMARY API CALL: GetIApplicantDataEndpoint (Requirement #2) // ============================================================================ const { data: applicantDataList = [], @@ -373,7 +259,7 @@ export function DashboardPage() { [partnerSummary.projectEntries] ); - // Downstream queries + // Downstream queries: Milestones, Evidence, Activities, Disbursements const { data: projectDetailsMap = {} } = useQuery< Record< string, @@ -448,189 +334,112 @@ export function DashboardPage() { 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; + 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) => { + 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; + return Number(valB) - Number(valA); } if (sortBy === 'days_left') { - return ( - calculateDaysLeft(a.project.endedAt || a.contract?.expiresAt) - - calculateDaysLeft(b.project.endedAt || b.contract?.expiresAt) - ); + 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]); - // Calculate Overview 2x2 metrics - const overviewMetrics = useMemo(() => { - let runningProjects = 0; - let completedProjects = 0; - let pendingPayment = 0; - let totalPayment = 0; + // 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; - 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); + 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); - if (st === ActivityStatus.Inactive || allMilestonesVerified) { - completedProjects += 1; - } else { - runningProjects += 1; - } + const totalDisbursed = enrichedProjects + .flatMap(p => p.disbursements) + .filter(d => Number(d.status) === ApprovalStatus.Approved) + .reduce((sum, d) => sum + Number(d.amount || 0), 0); - 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; - } + const totalEvidenceCount = enrichedProjects.flatMap(p => p.evidence).length; return { - runningProjects, - completedProjects, - pendingPayment: pendingPayment > 0 ? formatZAR(pendingPayment) : '$0', - totalPayment: totalPayment > 0 ? formatZAR(totalPayment) : '$0', + totalProjects, + totalMilestones: allMilestones.length, + completedMilestones: completedMilestones.length, + milestoneRate, + totalFunding, + totalDisbursed, + totalEvidenceCount, }; }, [enrichedProjects, partnerSummary]); - const activeProjectModalItem = useMemo(() => { - if (!selectedProjectItem) return null; - return ( - enrichedProjects.find( - x => String(x.project.id) === String(selectedProjectItem.project.id) - ) || selectedProjectItem - ); - }, [selectedProjectItem, enrichedProjects]); + // Toggle in-place project expansion (Requirement #4) + const handleToggleExpand = (projectId: string | number) => { + const key = String(projectId); + setExpandedProjectIds(prev => ({ + ...prev, + [key]: !prev[key], + })); + }; - // ============================================================================ - // 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); + // 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); - }, - }); + } + }; - 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.'); + // 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); } - 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), - }); + 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) { @@ -651,123 +460,53 @@ export function DashboardPage() { 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); - } - }; - + // Contact form submission const handleSendContactMessage = async (e: React.FormEvent) => { e.preventDefault(); if (!messageSubject.trim() || !messageBody.trim()) { - showInfoToast('Missing Fields', 'Please enter both a subject and message.'); + showErrorToast('Please provide 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; + const recipient = + contactTarget?.recipientId || + contactTarget?.recipientEmail || + contactTarget?.organisation?.name || + applicantUserId; + try { await sendMessage({ - recipientId: String(recipientId), - recipientEmail, - recipientName: targetOrg?.name || 'Funding Administrator', + recipient, 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.'); + showSuccessToast('Message Sent', 'Your communication was delivered to the programme team.'); + setContactTarget(null); setMessageSubject(''); setMessageBody(''); - setContactTarget(null); - } catch (err) { + } catch (err: any) { 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: 'contracts', label: 'Awards & Agreements' }, { id: 'analytics', label: 'Analytics' }, - { id: 'documents', label: 'Earnings' }, + { id: 'documents', label: 'Deliverables' }, ]; - // 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 + TOP NAVIGATION BAR — 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')} @@ -776,7 +515,7 @@ export function DashboardPage() {
- {/* Nav Tabs with bottom indicator on active */} + {/* Nav Tabs */}
- {/* Right: Search Pill + Refresh + Notification Bell + Profile */} -
- {/* Search Input: 280px x 38px */} -
- + {/* Right: Search, Notifications Bell, User Menu */} +
+ {/* Search Input */} +
+ 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" + 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 Data Button */} + {/* Refresh Projects Button */} - {/* Notification Bell */} + {/* Notifications Bell */}
+ {/* Bell Dropdown */} {showBellDropdown && ( -
-
-
- Notifications - {unreadCount > 0 && ( - - {unreadCount} unread - - )} -
+
+
+ Notifications {unreadCount > 0 && ( )}
-
+
{top5Unread.length === 0 ? ( -
- You are all caught up! No unread notifications. -
+
No unread notifications
) : ( top5Unread.map(item => ( - +

{item.subject}

+

{stripMarkdown(item.message)}

+
)) )}
- +
+ +
)}
- {/* Profile Avatar + Name + Dropdown */} + {/* User Profile Menu */}
{showUserDropdown && ( -
+
-

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

-

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

+

{user?.profile?.name || 'Partner User'}

+

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

{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) + MAIN DASHBOARD BODY (Max-width 1440px, full-width horizontal layout) + (Overview & Inbox sidebar deleted per Requirements #1, #2, #3) ===================================================================== */} -
-
- {isLoadingApplicantData ? ( -
-
+
+ {isLoadingApplicantData ? ( +
+ +

Loading your funding portfolio & projects...

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

Loading Workspace...

-

Retrieving ongoing projects and milestones.

+

+ Ongoing Projects +

+

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

+
+ + {/* Sort Selector */} +
+ +
- ) : 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. -
- -
-
-
+ {/* Full-width Projects List */} +
+ {filteredProjects.length > 0 ? ( + filteredProjects.map((item) => ( + handleToggleExpand(item.project.id)} + onUploadEvidence={(target) => setUploadTarget(target)} + onSubmitActivityForApproval={handleSubmitActivityForApproval} + onOpenSecureFile={handleOpenSecureFile} + /> + )) + ) : ( + /* Empty State */ +
+
+
+
- )} -
-
+
- {/* =============================================================== - RIGHT COLUMN: SIDEBAR WIDGETS (Overview & Inbox, 340px) - =============================================================== */} -
- - {/* 1. OVERVIEW CARD (2x2 Grid with vertical divider) */} -
-

- Overview +

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

-
- {/* 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. +

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

-
- -
-
- {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" +
+ {searchQuery && ( +
- ); - }) - )} -
-
- ) : 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'} - - -
-
- ); - })} -
+ + Clear Search + )} -
- - {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" - /> -
-