Files
pfm/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx
T

1210 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number, string> = {
[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<NavTab>('dashboard');
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<SortOption>('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<Record<string, boolean>>({});
// 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<Notification | null>(null);
const [contractFile, setContractFile] = useState<File | null>(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<ApplicantData[]>({
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<string, {
project: Project;
award: Award | null;
contract: Contract | null;
fundingOrganisation: Organisation | null;
invitedOrganisation: Organisation | null;
}>();
const contractsMap = new Map<string, {
contract: Contract;
award: Award | null;
fundingOrganisation: Organisation | null;
invitedOrganisation: Organisation | null;
}>();
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 (
<div className="min-h-screen w-full bg-[#0F0E17] text-[#F1F1F6] flex flex-col selection:bg-[#8B5CF6]/30 selection:text-[#F1F1F6]">
{/* =====================================================================
TOP NAVIGATION BAR — 72PX, FULL-WIDTH EDGE-TO-EDGE
===================================================================== */}
<header className="sticky top-0 z-30 w-full bg-[#0F0E17]/95 backdrop-blur-md border-b border-white/[0.06] h-[72px] flex items-center transition-all">
<div className="max-w-[1440px] mx-auto px-6 h-full flex items-center justify-between gap-4 w-full">
{/* Left: Brand Glyph & Horizontal Navigation Links */}
<div className="flex items-center gap-6 sm:gap-10 h-full">
<div
className="w-8 h-8 rounded-lg bg-white flex items-center justify-center shrink-0 cursor-pointer shadow-sm hover:opacity-90 transition-opacity"
onClick={() => setActiveTab('dashboard')}
title="Dashboard Home"
>
<InfinityIcon className="w-5 h-5 text-black -rotate-[30deg]" strokeWidth={2.5} />
</div>
{/* Nav Tabs */}
<nav className="hidden md:flex items-center gap-6 h-full">
{navTabs.map(tab => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`relative h-full flex items-center text-sm transition-colors cursor-pointer ${
isActive
? 'font-bold text-[#F1F1F6]'
: 'font-medium text-[#9A95B0] hover:text-[#F1F1F6]'
}`}
>
<span>{tab.label}</span>
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-[3px] bg-[#8B5CF6] rounded-t-[2px]" />
)}
</button>
);
})}
</nav>
</div>
{/* Right: Search, Notifications Bell, User Menu */}
<div className="flex items-center gap-3 sm:gap-4">
{/* Search Input */}
<div className="relative hidden sm:block w-48 md:w-64">
<Search className="w-4 h-4 text-[#9A95B0] absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
type="text"
placeholder="Search projects..."
value={searchQuery}
onChange={e => 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 && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[#9A95B0] hover:text-white"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Refresh Projects Button */}
<button
type="button"
onClick={() => refetchApplicantData()}
title="Refresh projects"
disabled={isRefreshingApplicantData}
className="p-2 rounded-xl bg-[#181622] border border-white/[0.06] text-[#9A95B0] hover:text-[#F1F1F6] transition-colors cursor-pointer disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isRefreshingApplicantData ? 'animate-spin text-[#8B5CF6]' : ''}`} />
</button>
{/* Notifications Bell */}
<div className="relative">
<button
type="button"
onClick={() => {
setShowBellDropdown(!showBellDropdown);
setShowUserDropdown(false);
}}
className="relative p-2 rounded-xl bg-[#181622] border border-white/[0.06] text-[#9A95B0] hover:text-[#F1F1F6] transition-colors cursor-pointer"
title="Notifications"
>
<Bell className="w-4 h-4" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 min-w-[16px] px-1 items-center justify-center rounded-full bg-[#8B5CF6] text-white text-[10px] font-bold ring-2 ring-[#0F0E17]">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{/* Bell Dropdown */}
{showBellDropdown && (
<div className="absolute right-0 mt-2 w-80 rounded-2xl bg-[#181622] border border-white/[0.08] shadow-2xl py-3 z-50">
<div className="px-4 pb-2 border-b border-white/[0.06] flex items-center justify-between">
<span className="text-xs font-semibold text-[#F1F1F6]">Notifications</span>
{unreadCount > 0 && (
<button
type="button"
onClick={() => markAllAsRead()}
className="text-[11px] text-[#8B5CF6] hover:underline cursor-pointer"
>
Mark all as read
</button>
)}
</div>
<div className="max-h-72 overflow-y-auto divide-y divide-white/[0.04]">
{top5Unread.length === 0 ? (
<div className="p-4 text-center text-xs text-[#9A95B0]">No unread notifications</div>
) : (
top5Unread.map(item => (
<div
key={item.id}
onClick={() => {
markAsRead(item.id);
setSelectedNotification(item);
setShowBellDropdown(false);
}}
className="p-3 hover:bg-white/[0.03] transition-colors cursor-pointer text-left"
>
<p className="text-xs font-semibold text-[#F1F1F6] truncate">{item.subject}</p>
<p className="text-[11px] text-[#9A95B0] line-clamp-2 mt-0.5">{stripMarkdown(item.message)}</p>
</div>
))
)}
</div>
<div className="pt-2 px-3 border-t border-white/[0.06] text-center">
<button
type="button"
onClick={() => {
setActiveTab('messages');
setShowBellDropdown(false);
}}
className="text-xs text-[#8B5CF6] hover:underline font-medium cursor-pointer"
>
View all messages
</button>
</div>
</div>
)}
</div>
{/* User Profile Menu */}
<div className="relative">
<button
type="button"
onClick={() => {
setShowUserDropdown(!showUserDropdown);
setShowBellDropdown(false);
}}
className="flex items-center gap-2 p-1.5 pl-2 rounded-xl bg-[#181622] border border-white/[0.06] hover:border-white/[0.12] transition-colors cursor-pointer"
>
<div className="w-6 h-6 rounded-lg bg-gradient-to-tr from-[#8B5CF6] to-indigo-600 flex items-center justify-center text-white text-[11px] font-bold">
{getInitials(user?.profile?.name || user?.profile?.email || 'Partner')}
</div>
<span className="text-xs font-medium text-[#F1F1F6] hidden md:inline max-w-[120px] truncate">
{user?.profile?.name || 'Partner Account'}
</span>
<ChevronDown className="w-3.5 h-3.5 text-[#9A95B0]" />
</button>
{showUserDropdown && (
<div className="absolute right-0 mt-2 w-56 rounded-2xl bg-[#181622] border border-white/[0.08] shadow-2xl p-2 z-50 text-xs">
<div className="px-3 py-2 border-b border-white/[0.06]">
<p className="font-semibold text-[#F1F1F6] truncate">{user?.profile?.name || 'Partner User'}</p>
<p className="text-[11px] text-[#9A95B0] truncate">{user?.profile?.email || ''}</p>
{partnerSummary.primaryInvitedOrg && (
<p className="text-[10px] text-emerald-400 mt-1 truncate">
{partnerSummary.primaryInvitedOrg.name}
</p>
)}
</div>
<button
type="button"
onClick={() => logout()}
className="w-full flex items-center gap-2 px-3 py-2 text-rose-400 hover:bg-rose-500/10 rounded-xl transition-colors cursor-pointer mt-1"
>
<LogOut className="w-3.5 h-3.5" />
<span>Sign Out</span>
</button>
</div>
)}
</div>
</div>
</div>
</header>
{/* =====================================================================
MAIN DASHBOARD BODY (Max-width 1440px, full-width horizontal layout)
(Overview & Inbox sidebar deleted per Requirements #1, #2, #3)
===================================================================== */}
<main className="flex-1 w-full max-w-[1440px] mx-auto px-6 py-8">
{isLoadingApplicantData ? (
<div className="py-24 flex flex-col items-center justify-center space-y-4">
<RefreshCw className="w-8 h-8 text-[#8B5CF6] animate-spin" />
<p className="text-sm text-[#9A95B0]">Loading your funding portfolio & projects...</p>
</div>
) : activeTab === 'dashboard' ? (
/* ===================================================================
DASHBOARD: FULL-WIDTH PROJECT LISTING (No Overview, No Inbox)
=================================================================== */
<div className="w-full space-y-6">
{/* Top Bar: Title & Sort Options */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h2 className="text-[20px] leading-[28px] font-bold text-[#F1F1F6]">
Ongoing Projects
</h2>
<p className="text-xs text-[#9A95B0] mt-0.5">
Click &ldquo;View Details&rdquo; on any project to inspect its milestones, track progress, and fulfill activity evidence requirements.
</p>
</div>
{/* Sort Selector */}
<div className="relative shrink-0">
<select
value={sortBy}
onChange={e => setSortBy(e.target.value as SortOption)}
className="appearance-none bg-[#181622] border border-white/[0.06] rounded-xl px-3 pr-8 py-2 text-xs font-medium text-[#F1F1F6] focus:outline-none cursor-pointer transition-colors"
>
<option value="date_added" className="bg-[#181622] text-[#F1F1F6]">Sort by date added</option>
<option value="grant_amount" className="bg-[#181622] text-[#F1F1F6]">Sort by award value</option>
<option value="days_left" className="bg-[#181622] text-[#F1F1F6]">Sort by days remaining</option>
<option value="name" className="bg-[#181622] text-[#F1F1F6]">Sort by project name</option>
</select>
<ChevronDown className="w-3.5 h-3.5 text-[#9A95B0] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
</div>
{/* Full-width Projects List */}
<div className="space-y-4">
{filteredProjects.length > 0 ? (
filteredProjects.map((item) => (
<ProjectCard
key={item.project.id}
item={item}
isExpanded={expandedProjectIds[String(item.project.id)] ?? false}
onToggleExpand={() => handleToggleExpand(item.project.id)}
onUploadEvidence={(target) => setUploadTarget(target)}
onSubmitActivityForApproval={handleSubmitActivityForApproval}
onOpenSecureFile={handleOpenSecureFile}
/>
))
) : (
/* Empty State */
<div className="rounded-[16px] bg-[#181622] border border-white/[0.06] p-8 sm:p-12 shadow-sm flex flex-col items-center justify-center text-center relative overflow-hidden">
<div className="relative mb-5">
<div className="w-14 h-14 rounded-2xl bg-[#8B5CF6]/15 border border-[#8B5CF6]/25 text-[#8B5CF6] flex items-center justify-center shadow-lg shadow-[#8B5CF6]/10">
<Sparkles className="w-7 h-7" />
</div>
</div>
<h3 className="text-[20px] font-bold text-[#F1F1F6] tracking-tight">
{searchQuery ? 'No Matching Projects Found' : 'No Active Projects Assigned Yet'}
</h3>
<p className="text-[14px] leading-[22px] font-normal text-[#9A95B0] max-w-lg mt-2">
{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."}
</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-[#F1F1F6] text-xs font-semibold transition-all cursor-pointer"
>
<X className="w-4 h-4" />
<span>Clear Search</span>
</button>
)}
<button
type="button"
onClick={() => refetchApplicantData()}
disabled={isRefreshingApplicantData}
className="inline-flex items-center gap-2.5 px-6 py-2.5 rounded-xl bg-[#8B5CF6] hover:bg-[#7C3AED] text-white font-semibold text-xs tracking-wide shadow-lg shadow-[#8B5CF6]/25 transition-all cursor-pointer disabled:opacity-60"
>
<RefreshCw className={`w-4 h-4 ${isRefreshingApplicantData ? 'animate-spin' : ''}`} />
<span>{isRefreshingApplicantData ? 'Refreshing Workspace...' : 'Refresh Projects'}</span>
</button>
</div>
</div>
)}
</div>
</div>
) : activeTab === 'messages' ? (
/* ===================================================================
MESSAGES TAB
=================================================================== */
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Messages & Communications</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5">
Direct communication with your funding programme officers.
</p>
</div>
<button
type="button"
onClick={() => {
setContactTarget({
organisation: partnerSummary.primaryFundingOrg,
projectTitle: 'Partner Support',
});
setMessageSubject('');
setMessageBody('');
}}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-[#8B5CF6] hover:bg-[#7C3AED] text-white font-semibold text-xs shadow-lg shadow-[#8B5CF6]/20 transition-all cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>New Message</span>
</button>
</div>
<div className="rounded-[16px] bg-[#181622] border border-white/[0.06] divide-y divide-white/[0.06] overflow-hidden">
{inboxNotifications.length === 0 ? (
<div className="p-12 text-center text-[#9A95B0] text-sm">
No new messages in your inbox yet. Messages sent by programme managers will be displayed here.
</div>
) : (
inboxNotifications.map(msg => {
const isUnread = Number(msg.status) !== NotificationStatus.Read;
return (
<div
key={msg.id}
onClick={() => {
markAsRead(msg.id);
setSelectedNotification(msg);
}}
className="p-5 hover:bg-white/[0.03] transition-colors flex items-start justify-between gap-4 cursor-pointer"
>
<div className="flex items-start gap-4 min-w-0">
<div className="h-10 w-10 rounded-full bg-[#8B5CF6]/15 border border-[#8B5CF6]/30 text-[#8B5CF6] font-bold text-xs flex items-center justify-center shrink-0">
{getInitials(msg.subject || 'PFM')}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-[#F1F1F6] truncate">
{msg.subject || 'Programme Notification'}
</h3>
{isUnread && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-[#EF4444] text-white">
New
</span>
)}
</div>
<p className="text-xs text-[#9A95B0] mt-1 line-clamp-2">{stripMarkdown(msg.message || '')}</p>
</div>
</div>
<span className="text-xs text-[#9A95B0]/70 shrink-0">
{formatFullDate(msg.createdAt)}
</span>
</div>
);
})
)}
</div>
</div>
) : activeTab === 'contracts' ? (
/* ===================================================================
CONTRACTS & AWARDS TAB
=================================================================== */
<div className="space-y-6">
<div>
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Contracts & Approved Awards</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5">
Review your approved funding awards and download or sign grant agreements.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{partnerSummary.contractEntries.length === 0 ? (
<div className="col-span-full rounded-[16px] bg-[#181622] border border-white/[0.06] p-10 text-center text-sm text-[#9A95B0]">
No contracts have been issued to your organisation yet.
</div>
) : (
partnerSummary.contractEntries.map(({ contract, award, fundingOrganisation }) => {
const isExecuted = Number(contract.status) === ContractStatus.Executed;
const contractVer = (contract as any).version || 1;
return (
<div
key={contract.id}
className="rounded-[16px] bg-[#181622] border border-white/[0.06] p-6 flex flex-col justify-between gap-5"
>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-[#8B5CF6] uppercase tracking-wider">
Agreement v{contractVer}
</span>
<span
className={`px-2.5 py-0.5 rounded-full text-xs font-semibold ${
isExecuted
? 'bg-emerald-500/15 text-emerald-300 border border-emerald-500/30'
: 'bg-[#F97316]/15 text-[#F97316] border border-[#F97316]/30'
}`}
>
{isExecuted ? 'Signed & Executed' : 'Action Required: Review & Sign'}
</span>
</div>
<h3 className="text-lg font-bold text-[#F1F1F6]">
{fundingOrganisation?.name || 'Grant Funding Agreement'}
</h3>
<div className="grid grid-cols-2 gap-4 pt-2 text-xs">
<div>
<p className="text-[#9A95B0]">Contract Value</p>
<p className="text-sm font-bold text-[#F1F1F6] mt-0.5">
{formatZAR(contract.totalValue || award?.amount || 0)}
</p>
</div>
<div>
<p className="text-[#9A95B0]">Valid Period</p>
<p className="text-[#D1CBDD] font-medium mt-0.5">
{formatShortDate(contract.effectiveAt)} – {formatFullDate(contract.expiresAt)}
</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 pt-4 border-t border-white/[0.06]">
<button
type="button"
onClick={() => handleOpenSecureFile('contract', contract.id)}
className="flex-1 py-2.5 px-4 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-[#F1F1F6] text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>Download PDF</span>
</button>
{!isExecuted && canAdd('grant_financials') && (
<button
type="button"
onClick={() => signContractMutation.mutate(contract)}
disabled={signContractMutation.isPending}
className="flex-1 py-2.5 px-4 rounded-xl bg-[#8B5CF6] hover:bg-[#7C3AED] text-white text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer disabled:opacity-50"
>
<CheckCircle2 className="w-3.5 h-3.5" />
<span>{signContractMutation.isPending ? 'Signing...' : 'Accept & Sign'}</span>
</button>
)}
</div>
</div>
);
})
)}
</div>
</div>
) : activeTab === 'analytics' ? (
/* ===================================================================
ANALYTICS TAB (Zero Mock Data: Real API Derived Values)
=================================================================== */
<div className="space-y-6">
<div>
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Portfolio Analytics</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5">
Real-time performance metrics and deliverable fulfillment rates across your assigned projects.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{/* Card 1: Milestone Fulfillment Rate */}
<div className="p-6 rounded-[16px] bg-[#181622] border border-white/[0.06]">
<div className="flex items-center justify-between text-[#8B5CF6] mb-2">
<TrendingUp className="w-5 h-5" />
<span className="text-xs font-semibold text-emerald-400">
{portfolioStats.completedMilestones} of {portfolioStats.totalMilestones} Milestones
</span>
</div>
<p className="text-2xl font-bold text-[#F1F1F6]">{portfolioStats.milestoneRate}%</p>
<p className="text-xs text-[#9A95B0] mt-1">Milestone Completion Rate</p>
</div>
{/* Card 2: Active Projects */}
<div className="p-6 rounded-[16px] bg-[#181622] border border-white/[0.06]">
<div className="flex items-center justify-between text-teal-400 mb-2">
<Briefcase className="w-5 h-5" />
<span className="text-xs font-semibold text-[#8B5CF6]">Active</span>
</div>
<p className="text-2xl font-bold text-[#F1F1F6]">{portfolioStats.totalProjects} Projects</p>
<p className="text-xs text-[#9A95B0] mt-1">Assigned Funded Projects</p>
</div>
{/* Card 3: Total Supporting Proofs */}
<div className="p-6 rounded-[16px] bg-[#181622] border border-white/[0.06]">
<div className="flex items-center justify-between text-amber-400 mb-2">
<AwardIcon className="w-5 h-5" />
<span className="text-xs font-semibold text-amber-400">Audited</span>
</div>
<p className="text-2xl font-bold text-[#F1F1F6]">{portfolioStats.totalEvidenceCount} Documents</p>
<p className="text-xs text-[#9A95B0] mt-1">Uploaded Evidence Deliverables</p>
</div>
</div>
</div>
) : (
/* ===================================================================
DOCUMENTS & DELIVERABLES TAB
=================================================================== */
<div className="space-y-6">
<div>
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Deliverables & Evidence Documents</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5">
Invoices, reports, and verified deliverable proofs submitted across projects.
</p>
</div>
<div className="rounded-[16px] bg-[#181622] border border-white/[0.06] overflow-hidden">
{enrichedProjects.flatMap(p => p.evidence.map(ev => ({ ev, project: p.project }))).length === 0 ? (
<div className="p-12 text-center space-y-3">
<p className="text-sm text-[#9A95B0]">
No documents uploaded yet. Expand any project on your Dashboard to upload proof files.
</p>
</div>
) : (
<div className="divide-y divide-white/[0.06]">
{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 (
<div
key={ev.id}
className="p-4 sm:p-5 flex items-center justify-between gap-4 hover:bg-white/[0.02]"
>
<div className="flex items-center gap-3.5 min-w-0">
<div className="p-2.5 rounded-xl bg-[#8B5CF6]/10 text-[#8B5CF6]">
<FileText className="w-5 h-5" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-[#F1F1F6] truncate">
{EVIDENCE_TYPE_LABELS[evType] || 'Supporting Document'}{' '}
<span className="text-xs font-normal text-[#9A95B0]">
(v{ev.version || 1})
</span>
</p>
<p className="text-xs text-[#9A95B0] truncate mt-0.5">
Project: {project.name} • Submitted {formatFullDate(evDate)}
</p>
</div>
</div>
<button
type="button"
onClick={() => handleOpenSecureFile('evidence', ev.id)}
className="px-3.5 py-2 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-xs font-semibold text-[#8B5CF6] flex items-center gap-1.5 shrink-0 cursor-pointer"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>View File</span>
</button>
</div>
);
})}
</div>
)}
</div>
</div>
)}
</main>
{/* =====================================================================
EVIDENCE UPLOAD MODAL (Supports Multiple Uploads per Activity)
(Requirements #8, #9, #10)
===================================================================== */}
{uploadTarget && (
<EvidenceUploadModal
isOpen={true}
onClose={() => 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 && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-md p-4">
<div className="w-full max-w-lg rounded-2xl bg-[#0d1322] border border-white/10 shadow-2xl p-6 space-y-4">
<div className="flex items-center justify-between border-b border-white/[0.08] pb-3">
<div className="flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-indigo-400" />
<h3 className="text-base font-bold text-white">Send Message</h3>
</div>
<button
type="button"
onClick={() => setContactTarget(null)}
className="p-1 rounded-lg text-slate-400 hover:text-white cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSendContactMessage} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-300 mb-1">Recipient</label>
<input
type="text"
disabled
value={`${contactTarget.organisation?.name || 'Programme Office'} (${contactTarget.projectTitle || 'Enquiry'})`}
className="w-full px-3.5 py-2 rounded-xl bg-slate-900 border border-white/10 text-xs text-slate-300"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-300 mb-1">Subject</label>
<input
type="text"
required
value={messageSubject}
onChange={e => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-300 mb-1">Message</label>
<textarea
rows={4}
required
value={messageBody}
onChange={e => setMessageBody(e.target.value)}
placeholder="Write your message here..."
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"
/>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setContactTarget(null)}
className="px-4 py-2 rounded-xl bg-slate-800 text-slate-300 text-xs font-semibold cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={isSending}
className="px-5 py-2 rounded-xl bg-indigo-500 hover:bg-indigo-400 text-white font-semibold text-xs flex items-center gap-2 cursor-pointer disabled:opacity-50"
>
<Send className="w-3.5 h-3.5" />
<span>{isSending ? 'Sending...' : 'Send Message'}</span>
</button>
</div>
</form>
</div>
</div>
)}
{/* =====================================================================
MESSAGE READER MODAL
===================================================================== */}
{selectedNotification && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-md p-4 animate-in fade-in duration-200">
<div className="w-full max-w-lg rounded-2xl bg-[#0d1322] border border-white/10 shadow-2xl p-6 space-y-4">
<div className="flex items-center justify-between border-b border-white/[0.08] pb-3">
<h3 className="text-base font-bold text-white truncate max-w-[360px]">
{selectedNotification.subject || 'Message Details'}
</h3>
<button
type="button"
onClick={() => setSelectedNotification(null)}
className="p-1 rounded-lg text-slate-400 hover:text-white cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3 text-xs">
<div className="flex items-center justify-between text-[#9A95B0]">
<span>From: {selectedNotification.destination?.split('|')[0] || 'Programme Management Office'}</span>
<span>{formatFullDate(selectedNotification.createdAt)}</span>
</div>
<div className="p-4 rounded-xl bg-slate-950/60 border border-white/5 text-slate-200 whitespace-pre-wrap leading-relaxed max-h-80 overflow-y-auto">
<MessageViewer
message={selectedNotification.message || ''}
subject={selectedNotification.subject || undefined}
createdDate={selectedNotification.createdAt}
platform={selectedNotification.platform}
variant="card"
/>
</div>
</div>
<div className="flex justify-end pt-2">
<button
type="button"
onClick={() => setSelectedNotification(null)}
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold cursor-pointer"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}