Files
pfm/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx
T
2026-10-23 00:58:47 +02:00

2180 lines
103 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,
Check,
Calendar,
FileText,
DollarSign,
Upload,
Send,
ChevronDown,
ShieldCheck,
MessageSquare,
FileCheck2,
X,
Plus,
ExternalLink,
CheckCircle2,
RefreshCw,
Briefcase,
Award as AwardIcon,
} from 'lucide-react';
import { useAuth } from '../../auth/AuthProvider';
import { useRbac } from '../../auth/rbac';
import { useToast } from '../ui/Toast';
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
import { useInAppNotifications } from '../../hooks/useInAppNotifications';
import {
getApplicantData,
getMilestones,
getProjectEvidence,
getActivities,
getDisbursements,
uploadEvidenceFile,
createActivity,
createDisbursement,
createMilestone,
uploadContractFile,
executeContract,
getContractSecureLink,
getEvidenceSecureLink,
} from '../../services/api';
import {
ApplicantData,
Project,
Milestone,
Evidence,
Activity,
Disbursement,
Contract,
Award,
Organisation,
ApprovalStatus,
ActivityStatus,
ContractStatus,
EvidenceType,
Notification,
NotificationPlatform,
NotificationStatus,
} from '../../types';
import { formatZAR } from '../../lib/formatters';
interface EnrichedPartnerProject {
project: Project;
award: Award | null;
contract: Contract | null;
fundingOrganisation: Organisation | null;
invitedOrganisation: Organisation | null;
milestones: Milestone[];
evidence: Evidence[];
activities: Activity[];
disbursements: Disbursement[];
}
type NavTab = 'dashboard' | 'messages' | 'contracts' | '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-ZA', { day: 'numeric', month: 'short' });
}
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-ZA', { 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);
// Modals state
const [selectedProjectItem, setSelectedProjectItem] = useState<EnrichedPartnerProject | null>(null);
const [projectModalTab, setProjectModalTab] = useState<'milestones' | 'evidence' | 'payments' | 'activities' | 'contract'>('milestones');
const [contactTarget, setContactTarget] = useState<{
organisation: Organisation | null;
projectTitle?: string;
recipientEmail?: string;
recipientId?: string;
} | null>(null);
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null);
// Forms inside Project Details Modal
const [uploadingEvidence, setUploadingEvidence] = useState(false);
const [selectedMilestoneForUpload, setSelectedMilestoneForUpload] = useState<string>('');
const [selectedEvidenceType, setSelectedEvidenceType] = useState<number>(EvidenceType.FinancialInvoiceOrReceipt);
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
const [paymentAmount, setPaymentAmount] = useState('');
const [paymentMilestoneId, setPaymentMilestoneId] = useState<string>('');
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<File | null>(null);
const [signatureRef, setSignatureRef] = useState('');
// Contact / Message form state
const [messageSubject, setMessageSubject] = useState('');
const [messageBody, setMessageBody] = useState('');
// In-App Notifications & Inbox hook
const {
inboxNotifications,
unreadCount,
top5Unread,
markAsRead,
markAllAsRead,
sendMessage,
isSending,
} = useInAppNotifications();
// ============================================================================
// PRIMARY STARTING API CALL: GetIApplicantDataEndpoint
// GET /v1/api/communications/applicants/{applicantUserId:Guid}/all
// ============================================================================
const {
data: applicantDataList = [],
isLoading: isLoadingApplicantData,
isFetching: isRefreshingApplicantData,
refetch: refetchApplicantData,
} = useQuery<ApplicantData[]>({
queryKey: ['partner-applicant-data', applicantUserId],
queryFn: () => getApplicantData(applicantUserId),
enabled: Boolean(applicantUserId),
staleTime: 30_000,
});
// Extract all unique projects, contracts, awards, and organisations from ApplicantData[]
const partnerSummary = useMemo(() => {
const projectsMap = new Map<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 using the IDs provided by GetIApplicantDataEndpoint
const { data: projectDetailsMap = {} } = useQuery<
Record<
string,
{
milestones: Milestone[];
evidence: Evidence[];
activities: Activity[];
disbursements: Disbursement[];
}
>
>({
queryKey: ['partner-project-details', projectIdsKey],
queryFn: async () => {
const entries = partnerSummary.projectEntries;
if (entries.length === 0) return {};
let allDisbursements: Disbursement[] = [];
try {
allDisbursements = await getDisbursements(1, 100);
} catch {
allDisbursements = [];
}
const results = await Promise.all(
entries.map(async ({ project }) => {
const pid = project.id;
const [milestones, evidence, activities] = await Promise.all([
getMilestones(pid).catch(() => [] as Milestone[]),
getProjectEvidence(pid).catch(() => [] as Evidence[]),
getActivities(pid).catch(() => [] as Activity[]),
]);
const projectDisbursements = allDisbursements.filter(
d => String(d.projectId) === String(pid)
);
return [
String(pid),
{
milestones,
evidence,
activities,
disbursements: projectDisbursements,
},
] as const;
})
);
return Object.fromEntries(results);
},
enabled: partnerSummary.projectEntries.length > 0,
staleTime: 30_000,
});
// Combine into EnrichedPartnerProject[]
const enrichedProjects: EnrichedPartnerProject[] = useMemo(() => {
return partnerSummary.projectEntries.map(entry => {
const details = projectDetailsMap[String(entry.project.id)] || {
milestones: [],
evidence: [],
activities: [],
disbursements: [],
};
return {
...entry,
...details,
};
});
}, [partnerSummary.projectEntries, projectDetailsMap]);
// Filter and sort projects for the "Ongoing Projects" section
const filteredProjects = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
const list = enrichedProjects.filter(item => {
if (!q) return true;
const titleMatch = (item.project.name || '').toLowerCase().includes(q);
const descMatch = (item.project.description || '').toLowerCase().includes(q);
const funderMatch = (item.fundingOrganisation?.name || '').toLowerCase().includes(q);
return titleMatch || descMatch || funderMatch;
});
return [...list].sort((a, b) => {
if (sortBy === 'name') {
return (a.project.name || '').localeCompare(b.project.name || '');
}
if (sortBy === 'grant_amount') {
const valA = a.contract?.totalValue || a.award?.amount || 0;
const valB = b.contract?.totalValue || b.award?.amount || 0;
return valB - valA;
}
if (sortBy === 'days_left') {
return (
calculateDaysLeft(a.project.endedAt || a.contract?.expiresAt) -
calculateDaysLeft(b.project.endedAt || b.contract?.expiresAt)
);
}
// Default: 'date_added' (newest 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 matching the template
const overviewMetrics = useMemo(() => {
let runningProjects = 0;
let completedProjects = 0;
let pendingPayment = 0;
let totalPayment = 0;
for (const item of enrichedProjects) {
const st = Number(item.project.status);
const allMilestonesVerified =
item.milestones.length > 0 &&
item.milestones.every(m => Number(m.status) === ApprovalStatus.Approved);
if (st === ActivityStatus.Inactive || allMilestonesVerified) {
completedProjects += 1;
} else {
runningProjects += 1;
}
for (const d of item.disbursements) {
const dStatus = Number(d.status);
if (dStatus === ApprovalStatus.Approved) {
totalPayment += Number(d.amount || 0);
} else if (
dStatus === ApprovalStatus.Pending ||
dStatus === ApprovalStatus.Review
) {
pendingPayment += Number(d.amount || 0);
}
}
}
// If no disbursements have been recorded yet, reflect total awarded/contracted grant value in Total Payment
if (totalPayment === 0 && pendingPayment === 0) {
const totalAwarded = partnerSummary.awardsList.reduce(
(sum, a) => sum + Number(a.amount || 0),
0
);
const totalContracted = partnerSummary.contractEntries.reduce(
(sum, c) => sum + Number(c.contract.totalValue || 0),
0
);
totalPayment = totalContracted || totalAwarded;
}
return {
runningProjects,
completedProjects,
pendingPayment,
totalPayment,
};
}, [enrichedProjects, partnerSummary]);
// Keep selectedProjectItem synchronized when queries refetch
const activeProjectModalItem = useMemo(() => {
if (!selectedProjectItem) return null;
return (
enrichedProjects.find(
x => String(x.project.id) === String(selectedProjectItem.project.id)
) || selectedProjectItem
);
}, [selectedProjectItem, enrichedProjects]);
// ============================================================================
// MUTATIONS (Simple Partner Actions with Toast Rules)
// ============================================================================
const uploadEvidenceMutation = useMutation({
mutationFn: async () => {
if (!activeProjectModalItem || !evidenceFile) {
throw new Error('Please choose a file to upload.');
}
setUploadingEvidence(true);
const formData = new FormData();
formData.append('file', evidenceFile);
formData.append('projectId', String(activeProjectModalItem.project.id));
if (selectedMilestoneForUpload) {
formData.append('milestoneId', selectedMilestoneForUpload);
}
formData.append('evidenceType', String(selectedEvidenceType));
return await uploadEvidenceFile(formData);
},
onSuccess: () => {
setUploadingEvidence(false);
setEvidenceFile(null);
showSuccessToast('Document Submitted', 'Document uploaded and linked to your project.');
queryClient.invalidateQueries({ queryKey: ['partner-project-details'] });
},
onError: (err) => {
setUploadingEvidence(false);
showErrorToast(err);
},
});
const requestPaymentMutation = useMutation({
mutationFn: async () => {
if (!activeProjectModalItem) throw new Error('No project selected.');
const amt = Number(paymentAmount);
if (!amt || amt <= 0) throw new Error('Please enter a valid payment amount greater than zero.');
return await createDisbursement({
projectId: activeProjectModalItem.project.id,
milestoneId: paymentMilestoneId ? paymentMilestoneId : null,
amount: amt,
});
},
onSuccess: () => {
setPaymentAmount('');
setPaymentMilestoneId('');
showSuccessToast('Payment Requested', 'Your payment request has been submitted for review.');
queryClient.invalidateQueries({ queryKey: ['partner-project-details'] });
},
onError: (err) => showErrorToast(err),
});
const logActivityMutation = useMutation({
mutationFn: async () => {
if (!activeProjectModalItem) throw new Error('No project selected.');
if (!activityTitle.trim()) throw new Error('Please enter a short title for this update.');
return await createActivity({
projectId: activeProjectModalItem.project.id,
category: activityCategory,
amount: Number(activityAmount) || 0,
title: activityTitle.trim(),
description: activityDescription.trim(),
performedAt: new Date().toISOString(),
});
},
onSuccess: () => {
setActivityTitle('');
setActivityAmount('');
setActivityDescription('');
showSuccessToast('Update Saved', 'Project progress update logged.');
queryClient.invalidateQueries({ queryKey: ['partner-project-details'] });
},
onError: (err) => showErrorToast(err),
});
const addMilestoneMutation = useMutation({
mutationFn: async () => {
if (!activeProjectModalItem) throw new Error('No project selected.');
if (!newMilestoneName.trim() || !newMilestoneDueAt) {
throw new Error('Please provide both a milestone name and due date.');
}
return await createMilestone({
projectId: activeProjectModalItem.project.id,
name: newMilestoneName.trim(),
dueAt: newMilestoneDueAt,
priority: 1,
});
},
onSuccess: () => {
setNewMilestoneName('');
setNewMilestoneDueAt('');
showSuccessToast('Milestone Added', 'Milestone added to your project schedule.');
queryClient.invalidateQueries({ queryKey: ['partner-project-details'] });
},
onError: (err) => showErrorToast(err),
});
const signContractMutation = useMutation({
mutationFn: async (contract: Contract) => {
if (contractFile) {
await uploadContractFile(contract.id, contractFile);
}
await executeContract({
contractId: contract.id,
externalSignatureId: signatureRef.trim() || `SIG-${Date.now()}`,
signedDocumentUrl: contract.signedDocumentUrl || `s3://contracts/${contract.id}/signed.pdf`,
});
},
onSuccess: () => {
setContractFile(null);
setSignatureRef('');
showSuccessToast('Agreement Executed', 'Contract signed and submitted.');
queryClient.invalidateQueries({ queryKey: ['partner-applicant-data'] });
},
onError: (err) => showErrorToast(err),
});
const handleOpenSecureFile = async (type: 'contract' | 'evidence', id: string | number) => {
try {
const link =
type === 'contract'
? await getContractSecureLink(id)
: await getEvidenceSecureLink(id);
if (link) {
window.open(link, '_blank', 'noopener,noreferrer');
} else {
showInfoToast('File Unavailable', 'No file URL is attached to this record yet.');
}
} catch (err) {
showErrorToast(err);
}
};
const handleSendContactMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!messageSubject.trim() || !messageBody.trim()) {
showInfoToast('Missing Fields', 'Please enter both a subject and message.');
return;
}
try {
const targetOrg = contactTarget?.organisation || partnerSummary.primaryFundingOrg;
const recipientEmail =
contactTarget?.recipientEmail ||
targetOrg?.email ||
'support@pfm.gov.za';
const recipientId =
contactTarget?.recipientId ||
targetOrg?.userId ||
applicantUserId;
await sendMessage({
recipientId: String(recipientId),
recipientEmail,
recipientName: targetOrg?.name || 'Funding Administrator',
subject: messageSubject.trim(),
message: messageBody.trim(),
platform: NotificationPlatform.InApp,
organisationId: targetOrg?.id || 1,
});
showSuccessToast('Message Sent', 'Your message has been sent to the funding team.');
setMessageSubject('');
setMessageBody('');
setContactTarget(null);
} catch (err) {
showErrorToast(err);
}
};
const openContactModalForProject = (item: EnrichedPartnerProject) => {
setContactTarget({
organisation: item.fundingOrganisation || partnerSummary.primaryFundingOrg,
projectTitle: item.project.name || 'Grant Project',
recipientEmail: item.fundingOrganisation?.email || undefined,
recipientId: item.fundingOrganisation?.userId || undefined,
});
setMessageSubject(`Enquiry regarding ${item.project.name || 'Project'}`);
setMessageBody('');
};
return (
<div className="min-h-screen w-full bg-slate-950 text-slate-100 flex flex-col selection:bg-emerald-500/30 selection:text-emerald-200">
{/* Subtle Ambient Background Glow honoring PartnerUI's emerald/teal/cyan palette */}
<div className="pointer-events-none fixed inset-0 overflow-hidden z-0">
<div className="absolute -top-40 left-1/4 h-96 w-96 rounded-full bg-emerald-500/8 blur-[130px]" />
<div className="absolute top-1/3 right-10 h-96 w-96 rounded-full bg-teal-500/6 blur-[140px]" />
<div className="absolute bottom-0 left-1/3 h-80 w-80 rounded-full bg-cyan-500/5 blur-[130px]" />
</div>
{/* =====================================================================
TOP NAVIGATION BAR (Matches Uploaded Template + Dark Emerald Theme)
===================================================================== */}
<header className="sticky top-0 z-30 border-b border-white/[0.07] bg-[#0b101e]/90 backdrop-blur-xl">
<div className="mx-auto max-w-[1440px] px-4 sm:px-8 h-20 flex items-center justify-between gap-4">
{/* Left: Brand Logo & Navigation Links */}
<div className="flex items-center gap-8">
<button
type="button"
onClick={() => setActiveTab('dashboard')}
className="flex items-center gap-3 text-left group focus:outline-none cursor-pointer"
>
<PfmPartnerLogo size={38} />
<div>
<div className="flex items-center gap-2">
<span className="text-base font-bold tracking-tight text-white group-hover:text-emerald-300 transition-colors">
PFM Partner
</span>
<span className="hidden sm:inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
Grantee Portal
</span>
</div>
<p className="text-[11px] text-slate-400 hidden sm:block">
{partnerSummary.primaryInvitedOrg?.name || user?.name || 'Partner Workspace'}
</p>
</div>
</button>
<nav className="hidden lg:flex items-center gap-1">
{(
[
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'messages', label: 'Messages', badge: unreadCount },
{ id: 'contracts', label: 'Contracts & Grants' },
{ id: 'documents', label: 'Deliverables & Documents' },
] as { id: NavTab; label: string; badge?: number }[]
).map(tab => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`relative px-4 py-2.5 text-sm font-medium transition-colors rounded-lg flex items-center gap-2 cursor-pointer ${
isActive
? 'text-emerald-300 bg-emerald-500/10'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/[0.03]'
}`}
>
<span>{tab.label}</span>
{tab.badge !== undefined && tab.badge > 0 && (
<span className="px-1.5 py-0.2 rounded-full text-[10px] font-bold bg-rose-500 text-white">
{tab.badge}
</span>
)}
{isActive && (
<span className="absolute bottom-0 left-3 right-3 h-0.5 rounded-full bg-gradient-to-r from-emerald-400 to-teal-300" />
)}
</button>
);
})}
</nav>
</div>
{/* Right: Search Input, Notification Bell, Partner Profile & Sign Out */}
<div className="flex items-center gap-3 sm:gap-4">
<div className="relative hidden md:block w-60 lg:w-72">
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search projects..."
className="w-full pl-10 pr-4 py-2 rounded-xl bg-slate-900/90 border border-white/[0.08] text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/30 transition-all"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<button
type="button"
onClick={() => refetchApplicantData()}
title="Refresh Partner Data"
className="p-2.5 rounded-xl bg-slate-900/80 border border-white/[0.08] text-slate-400 hover:text-emerald-300 hover:border-emerald-500/30 transition-all cursor-pointer"
>
<RefreshCw className={`w-4 h-4 ${isRefreshingApplicantData ? 'animate-spin text-emerald-400' : ''}`} />
</button>
{/* Notification Bell */}
<div className="relative">
<button
type="button"
onClick={() => setShowBellDropdown(prev => !prev)}
className="relative p-2.5 rounded-xl bg-slate-900/80 border border-white/[0.08] text-slate-300 hover:text-white hover:border-emerald-500/30 transition-all cursor-pointer"
aria-label="Notifications"
>
<Bell className="w-4 h-4" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 h-4 min-w-[16px] px-1 rounded-full bg-rose-500 text-[10px] font-bold text-white flex items-center justify-center shadow">
{unreadCount}
</span>
)}
</button>
{showBellDropdown && (
<div className="absolute right-0 mt-2 w-80 sm:w-96 rounded-2xl bg-[#0d1322] border border-white/10 shadow-2xl p-4 z-50">
<div className="flex items-center justify-between pb-3 border-b border-white/[0.08]">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white">Notifications</span>
{unreadCount > 0 && (
<span className="px-2 py-0.5 text-[11px] font-semibold rounded-full bg-emerald-500/15 text-emerald-300">
{unreadCount} unread
</span>
)}
</div>
{unreadCount > 0 && (
<button
type="button"
onClick={() => markAllAsRead()}
className="text-xs text-emerald-400 hover:text-emerald-300 font-medium cursor-pointer"
>
Mark all read
</button>
)}
</div>
<div className="divide-y divide-white/[0.06] max-h-72 overflow-y-auto my-2">
{top5Unread.length === 0 ? (
<div className="py-8 text-center text-xs text-slate-400">
You are all caught up! No new notifications.
</div>
) : (
top5Unread.map(item => (
<button
key={item.id}
type="button"
onClick={() => {
markAsRead(item.id);
setSelectedNotification(item);
setShowBellDropdown(false);
}}
className="w-full text-left py-3 px-2 hover:bg-white/[0.04] rounded-lg transition-colors flex items-start gap-3 cursor-pointer"
>
<span className="h-2 w-2 rounded-full bg-rose-500 mt-1.5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-white truncate">
{item.subject || 'New Partner Update'}
</p>
<p className="text-xs text-slate-400 line-clamp-2 mt-0.5">
{item.message}
</p>
</div>
</button>
))
)}
</div>
<button
type="button"
onClick={() => {
setShowBellDropdown(false);
setActiveTab('messages');
}}
className="w-full py-2 text-center text-xs font-semibold text-emerald-300 hover:text-emerald-200 bg-emerald-500/10 hover:bg-emerald-500/15 rounded-xl transition-colors cursor-pointer"
>
See All Messages
</button>
</div>
)}
</div>
{/* User Avatar & Sign Out */}
<div className="flex items-center gap-3 pl-2 border-l border-white/[0.08]">
<div className="h-9 w-9 rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-slate-950 font-bold text-xs flex items-center justify-center shadow-sm">
{getInitials(user?.name || user?.email)}
</div>
<div className="hidden xl:block text-left">
<p className="text-xs font-semibold text-white leading-tight truncate max-w-[140px]">
{user?.name || user?.email || 'Partner User'}
</p>
<p className="text-[11px] text-emerald-400 font-medium">Partner Role</p>
</div>
<button
type="button"
onClick={() => logout()}
title="Sign Out"
className="p-2 rounded-xl text-slate-400 hover:text-rose-300 hover:bg-rose-500/10 transition-colors cursor-pointer"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Mobile Navigation Bar */}
<div className="flex lg:hidden items-center gap-1 px-4 pb-2 overflow-x-auto">
{(
[
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'messages', label: `Messages (${unreadCount})` },
{ id: 'contracts', label: 'Contracts' },
{ id: 'documents', label: 'Documents' },
] as { id: NavTab; label: string }[]
).map(tab => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap ${
activeTab === tab.id
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
: 'text-slate-400'
}`}
>
{tab.label}
</button>
))}
</div>
</header>
{/* =====================================================================
MAIN CONTENT AREA
===================================================================== */}
<main className="relative z-10 flex-1 mx-auto w-full max-w-[1440px] px-4 sm:px-8 py-8">
{isLoadingApplicantData ? (
<div className="py-24 flex flex-col items-center justify-center gap-4 text-center">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-emerald-400 border-t-transparent" />
<div>
<p className="text-base font-semibold text-white">Loading your Partner Workspace...</p>
<p className="text-xs text-slate-400 mt-1">
Retrieving your projects, funding awards, contracts, and milestones.
</p>
</div>
</div>
) : activeTab === 'dashboard' ? (
/* =================================================================
DASHBOARD VIEW — 2-COLUMN LAYOUT BASED ON UPLOADED TEMPLATE
Left (~2/3): "Ongoing Projects" + "Sort by date added" + Cards
Right (~1/3): "Overview" (2x2 Grid) + "Inbox"
================================================================= */
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8 items-start">
{/* LEFT COLUMN: ONGOING PROJECTS (8 cols on xl) */}
<div className="xl:col-span-8 space-y-5">
{/* Section Header */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
Ongoing Projects
</h1>
<p className="text-xs text-slate-400 mt-0.5">
Track your grant milestones, submit proof documents, and request payments simply.
</p>
</div>
{/* Sort Selector matching "Sort by date added v" in template */}
<div className="flex items-center gap-2">
<div className="relative">
<select
value={sortBy}
onChange={e => setSortBy(e.target.value as SortOption)}
className="appearance-none bg-[#0d1322] border border-white/10 hover:border-emerald-500/40 rounded-xl pl-3.5 pr-9 py-2 text-xs font-medium text-slate-300 focus:outline-none focus:border-emerald-500 transition-colors cursor-pointer"
>
<option value="date_added">Sort by date added</option>
<option value="grant_amount">Sort by grant amount</option>
<option value="days_left">Sort by days left</option>
<option value="name">Sort by project name</option>
</select>
<ChevronDown className="w-3.5 h-3.5 text-slate-400 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
</div>
</div>
{/* Project Cards List */}
{filteredProjects.length === 0 ? (
<div className="rounded-2xl bg-[#0d1322]/90 border border-white/[0.08] p-10 text-center space-y-4">
<div className="mx-auto h-12 w-12 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-300">
<Briefcase className="w-6 h-6" />
</div>
<div className="max-w-md mx-auto">
<h3 className="text-base font-semibold text-white">
{searchQuery ? 'No matching projects found' : 'No active projects assigned yet'}
</h3>
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
{searchQuery
? 'Try clearing your search filter to see all your funded projects.'
: 'Once your funding organisation registers your approved grant award and project, it will appear right here automatically.'}
</p>
</div>
{partnerSummary.contractEntries.length > 0 && (
<button
type="button"
onClick={() => setActiveTab('contracts')}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-500 text-slate-950 font-semibold text-xs hover:bg-emerald-400 transition-colors cursor-pointer"
>
<FileCheck2 className="w-4 h-4" />
<span>View Your Funding Contracts ({partnerSummary.contractEntries.length})</span>
</button>
)}
</div>
) : (
<div className="space-y-5">
{filteredProjects.map(item => {
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;
// Build 5-node stepper matching the template:
// [Project start] --- [Milestone 1] --- [Milestone 2] --- [Milestone 3] --- [Milestone 4]
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',
dateLabel: formatShortDate(project.startedAt || contract?.effectiveAt || project.createdAt),
state: 'completed' as 'completed' | 'current' | 'upcoming',
},
...[0, 1, 2, 3].map(idx => {
const m = sortedMilestones[idx];
if (!m) {
return {
id: `m-placeholder-${idx}`,
label: `Milestone ${idx + 1}`,
dateLabel: 'Planned',
state: '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}`,
dateLabel: formatShortDate(m.dueAt),
state: isDone ? ('completed' as const) : isCurrent ? ('current' as const) : ('upcoming' as const),
};
}),
];
const funderName =
fundingOrganisation?.name ||
partnerSummary.primaryFundingOrg?.name ||
'Funding Organisation';
return (
<div
key={project.id}
className="rounded-2xl bg-[#0d1322]/95 border border-white/[0.08] hover:border-emerald-500/30 transition-all shadow-lg overflow-hidden"
>
<div className="grid grid-cols-1 lg:grid-cols-12">
{/* LEFT PART OF CARD (9 cols): Title, 4 Metrics Row, Horizontal Milestone Stepper */}
<div className="lg:col-span-9 p-6 sm:p-7 flex flex-col justify-between gap-6">
{/* Project Title & Description */}
<div className="space-y-1.5">
<div className="flex items-center gap-2.5 flex-wrap">
<h2 className="text-lg font-bold text-white tracking-tight">
{project.name || 'Funded Project'}
</h2>
<span className="px-2.5 py-0.5 rounded-full text-[11px] font-medium bg-emerald-500/15 text-emerald-300 border border-emerald-500/25">
{Number(project.status) === ActivityStatus.Inactive ? 'Completed' : 'Active Grant'}
</span>
</div>
{project.description && (
<p className="text-xs text-slate-400 line-clamp-1">
{project.description}
</p>
)}
</div>
{/* 4 Metric Items Row (Matching template's 4 icon+value+sublabel columns) */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 py-1">
{/* Metric 1: Total Grant */}
<div className="flex items-start gap-3">
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400 shrink-0 mt-0.5">
<DollarSign className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-bold text-white">
{formatZAR(grantAmount)}
</p>
<p className="text-[11px] text-slate-400">Awarded Grant</p>
</div>
</div>
{/* Metric 2: Paid / Disbursed */}
<div className="flex items-start gap-3">
<div className="p-2 rounded-xl bg-teal-500/10 text-teal-300 shrink-0 mt-0.5">
<AwardIcon className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-bold text-white">
{formatZAR(disbursedAmount)}
</p>
<p className="text-[11px] text-slate-400">Funds Received</p>
</div>
</div>
{/* Metric 3: Days Left */}
<div className="flex items-start gap-3">
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-300 shrink-0 mt-0.5">
<Calendar className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-bold text-white">
{daysLeft} days
</p>
<p className="text-[11px] text-slate-400">Days left</p>
</div>
</div>
{/* Metric 4: Documents / Deliverables Submitted */}
<div className="flex items-start gap-3">
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-300 shrink-0 mt-0.5">
<FileText className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-bold text-white">
{docsSubmitted} docs
</p>
<p className="text-[11px] text-slate-400">Proof uploaded</p>
</div>
</div>
</div>
{/* Horizontal Milestone Stepper (Matching template's Project start -> Milestone 1..4) */}
<div className="pt-2 overflow-x-auto">
<div className="min-w-[480px] flex items-start justify-between relative">
{stepperNodes.map((node, idx) => {
const isLast = idx === stepperNodes.length - 1;
const nextNode = stepperNodes[idx + 1];
const lineCompleted =
node.state === 'completed' &&
(nextNode?.state === 'completed' || nextNode?.state === 'current');
return (
<div
key={node.id}
className="flex-1 flex flex-col items-start relative"
>
{/* Node Circle + Connecting Line */}
<div className="w-full flex items-center">
{node.state === 'completed' ? (
<div className="h-6 w-6 rounded-full bg-emerald-500 text-slate-950 flex items-center justify-center shadow-sm shadow-emerald-500/30 shrink-0 z-10">
<Check className="w-3.5 h-3.5 stroke-[3]" />
</div>
) : node.state === 'current' ? (
<div className="h-6 w-6 rounded-full bg-slate-900 border-2 border-emerald-400 flex items-center justify-center shrink-0 z-10">
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
</div>
) : (
<div className="h-6 w-6 rounded-full bg-slate-900 border-2 border-slate-700 shrink-0 z-10" />
)}
{!isLast && (
<div
className={`h-0.5 flex-1 mx-2 rounded-full ${
lineCompleted
? 'bg-emerald-500'
: 'bg-slate-800'
}`}
/>
)}
</div>
{/* Node Title & Date */}
<div className="mt-2.5 pr-3">
<p
className={`text-xs font-semibold truncate max-w-[115px] ${
node.state === 'upcoming'
? 'text-slate-400'
: 'text-slate-100'
}`}
title={node.label}
>
{node.label}
</p>
<p className="text-[11px] text-slate-500 mt-0.5">
{node.dateLabel}
</p>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* RIGHT PART OF CARD (3 cols): Funder Info + "Contact" & "View Details" Buttons */}
<div className="lg:col-span-3 border-t lg:border-t-0 lg:border-l border-white/[0.07] bg-slate-900/40 p-6 flex flex-col justify-between gap-6">
{/* Funder Profile Block */}
<div className="flex items-start gap-3">
<div className="h-11 w-11 rounded-xl bg-gradient-to-br from-emerald-500/20 to-teal-500/20 border border-emerald-500/30 text-emerald-300 font-bold text-sm flex items-center justify-center shrink-0">
{getInitials(funderName)}
</div>
<div className="min-w-0">
<p className="text-sm font-bold text-white truncate" title={funderName}>
{funderName}
</p>
<p className="text-[11px] text-slate-400 truncate mt-0.5">
{fundingOrganisation?.email || 'Funding Partner'}
</p>
<div className="mt-2 inline-flex items-center gap-1 text-[11px] text-emerald-400 font-medium">
<ShieldCheck className="w-3.5 h-3.5" />
<span>Verified Grantor</span>
</div>
</div>
</div>
{/* Stacked Action Buttons: Contact & View Details */}
<div className="space-y-2.5">
<button
type="button"
onClick={() => openContactModalForProject(item)}
className="w-full py-2.5 px-4 rounded-xl bg-slate-800/90 hover:bg-slate-800 text-slate-200 border border-white/10 text-xs font-semibold transition-all cursor-pointer"
>
Contact
</button>
<button
type="button"
onClick={() => {
setSelectedProjectItem(item);
setProjectModalTab('milestones');
}}
className="w-full py-2.5 px-4 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold text-xs shadow-lg shadow-emerald-500/15 transition-all cursor-pointer"
>
View Details
</button>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* RIGHT COLUMN: OVERVIEW (2x2 GRID) + INBOX (4 cols on xl) */}
<div className="xl:col-span-4 space-y-6">
{/* 1. OVERVIEW CARD */}
<div className="space-y-3">
<h2 className="text-xl font-bold text-white tracking-tight">Overview</h2>
<div className="rounded-2xl bg-[#0d1322]/95 border border-white/[0.08] shadow-lg overflow-hidden">
<div className="grid grid-cols-2 divide-x divide-white/[0.07]">
{/* Top-Left: Running Projects */}
<div className="p-6">
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
{overviewMetrics.runningProjects}
</p>
<p className="text-xs text-slate-400 mt-1">Running Projects</p>
</div>
{/* Top-Right: Pending Payment */}
<div className="p-6">
<p className="text-xl sm:text-2xl font-bold text-emerald-300 tracking-tight truncate">
{formatZAR(overviewMetrics.pendingPayment)}
</p>
<p className="text-xs text-slate-400 mt-1">Pending Payment</p>
</div>
</div>
<div className="grid grid-cols-2 divide-x divide-white/[0.07] border-t border-white/[0.07]">
{/* Bottom-Left: Completed Projects */}
<div className="p-6">
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
{overviewMetrics.completedProjects}
</p>
<p className="text-xs text-slate-400 mt-1">Completed Projects</p>
</div>
{/* Bottom-Right: Total Payment */}
<div className="p-6">
<p className="text-xl sm:text-2xl font-bold text-white tracking-tight truncate">
{formatZAR(overviewMetrics.totalPayment)}
</p>
<p className="text-xs text-slate-400 mt-1">Total Payment</p>
</div>
</div>
</div>
</div>
{/* 2. INBOX CARD */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white tracking-tight">Inbox</h2>
{unreadCount > 0 && (
<span className="px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
{unreadCount}
</span>
)}
</div>
<button
type="button"
onClick={() => setActiveTab('messages')}
className="text-xs font-semibold text-slate-400 hover:text-emerald-300 transition-colors cursor-pointer"
>
See All
</button>
</div>
<div className="rounded-2xl bg-[#0d1322]/95 border border-white/[0.08] shadow-lg divide-y divide-white/[0.06] overflow-hidden">
{inboxNotifications.length === 0 ? (
<div className="p-8 text-center space-y-3">
<div className="mx-auto h-10 w-10 rounded-xl bg-slate-900 border border-white/10 flex items-center justify-center text-slate-400">
<MessageSquare className="w-5 h-5" />
</div>
<div>
<p className="text-sm font-semibold text-white">Your inbox is empty</p>
<p className="text-xs text-slate-400 mt-0.5">
Messages from your funding administrator will appear here.
</p>
</div>
<button
type="button"
onClick={() =>
setContactTarget({
organisation: partnerSummary.primaryFundingOrg,
projectTitle: 'General Partner Enquiry',
})
}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-300 text-xs font-semibold transition-colors cursor-pointer"
>
<Send className="w-3.5 h-3.5" />
<span>Send a Message</span>
</button>
</div>
) : (
inboxNotifications.slice(0, 6).map(msg => {
const isUnread = Number(msg.status) !== NotificationStatus.Read;
const senderLabel =
msg.destination?.split('|')[0]?.replace('To:', '').trim() ||
partnerSummary.primaryFundingOrg?.name ||
'Funding Office';
return (
<button
key={msg.id}
type="button"
onClick={() => {
markAsRead(msg.id);
setSelectedNotification(msg);
}}
className="w-full p-4 text-left hover:bg-white/[0.03] transition-colors flex items-start gap-3.5 cursor-pointer"
>
<div className="h-10 w-10 rounded-full bg-slate-800 border border-white/10 text-emerald-300 font-bold text-xs flex items-center justify-center shrink-0">
{getInitials(msg.subject || senderLabel)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<p className="text-xs font-bold text-white truncate">
{msg.subject || senderLabel}
</p>
{isUnread && (
<span className="h-2 w-2 rounded-full bg-rose-500 shrink-0" />
)}
</div>
<span className="text-[10px] text-slate-500 shrink-0">
{formatShortDate(msg.createdAt)}
</span>
</div>
<p className="text-xs text-slate-400 truncate mt-1">
{msg.message}
</p>
</div>
</button>
);
})
)}
</div>
</div>
</div>
</div>
) : activeTab === 'messages' ? (
/* =================================================================
MESSAGES TAB — Simple Non-Technical Partner Inbox & Compose
================================================================= */
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Messages & Communications</h1>
<p className="text-xs text-slate-400 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-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold text-xs shadow-lg shadow-emerald-500/15 transition-all cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>New Message</span>
</button>
</div>
<div className="rounded-2xl bg-[#0d1322]/95 border border-white/[0.08] divide-y divide-white/[0.06] overflow-hidden">
{inboxNotifications.length === 0 ? (
<div className="p-12 text-center text-slate-400 text-sm">
No messages in your inbox yet.
</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-emerald-500/15 border border-emerald-500/30 text-emerald-300 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-bold text-white truncate">
{msg.subject || 'Programme Notification'}
</h3>
{isUnread && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-rose-500 text-white">
New
</span>
)}
</div>
<p className="text-xs text-slate-300 mt-1 line-clamp-2">{msg.message}</p>
</div>
</div>
<span className="text-xs text-slate-500 shrink-0">
{formatFullDate(msg.createdAt)}
</span>
</div>
);
})
)}
</div>
</div>
) : activeTab === 'contracts' ? (
/* =================================================================
CONTRACTS & GRANTS TAB — View & Sign Contracts Simply
================================================================= */
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Contracts & Approved Grants</h1>
<p className="text-xs text-slate-400 mt-0.5">
Review your approved funding awards and sign or download your 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-2xl bg-[#0d1322]/95 border border-white/[0.08] p-10 text-center text-sm text-slate-400">
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-2xl bg-[#0d1322]/95 border border-white/[0.08] 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-emerald-400 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-amber-500/15 text-amber-300 border border-amber-500/30'
}`}
>
{isExecuted ? 'Signed & Executed' : 'Action Required: Review & Sign'}
</span>
</div>
<h3 className="text-lg font-bold text-white">
{fundingOrganisation?.name || 'Grant Funding Agreement'}
</h3>
<div className="grid grid-cols-2 gap-4 pt-2 text-xs">
<div>
<p className="text-slate-400">Contract Value</p>
<p className="text-sm font-bold text-white mt-0.5">
{formatZAR(contract.totalValue || award?.amount || 0)}
</p>
</div>
<div>
<p className="text-slate-400">Valid Period</p>
<p className="text-slate-200 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.07]">
<button
type="button"
onClick={() => handleOpenSecureFile('contract', contract.id)}
className="flex-1 py-2.5 px-4 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 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-emerald-500 hover:bg-emerald-400 text-slate-950 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>
) : (
/* =================================================================
DELIVERABLES & DOCUMENTS TAB — All Uploaded Evidence Across Projects
================================================================= */
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Deliverables & Proof Documents</h1>
<p className="text-xs text-slate-400 mt-0.5">
All reports, invoices, and milestone proof files submitted across your projects.
</p>
</div>
<div className="rounded-2xl bg-[#0d1322]/95 border border-white/[0.08] 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-slate-400">
No documents uploaded yet. Open any project on your Dashboard and click "View Details" to upload proof documents.
</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-emerald-500/10 text-emerald-300">
<FileText className="w-5 h-5" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-white truncate">
{EVIDENCE_TYPE_LABELS[evType] || 'Supporting Document'}{' '}
<span className="text-xs font-normal text-slate-400">
(v{ev.version || 1})
</span>
</p>
<p className="text-xs text-slate-400 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-slate-800 hover:bg-slate-700 text-xs font-semibold text-emerald-300 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>
{/* =====================================================================
MODAL 1: PROJECT WORKSPACE ("View Details" button on Project Card)
Simple, non-technical tabs for Milestones, Proof Upload, Payments, Updates
===================================================================== */}
{activeProjectModalItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-md p-4 overflow-y-auto">
<div className="w-full max-w-4xl rounded-2xl bg-[#0d1322] border border-white/10 shadow-2xl overflow-hidden my-8">
{/* Modal Header */}
<div className="p-6 border-b border-white/[0.08] flex items-start justify-between gap-4 bg-slate-900/60">
<div>
<div className="flex items-center gap-2">
<span className="px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
Project Workspace
</span>
<span className="text-xs text-slate-400">
Funder: {activeProjectModalItem.fundingOrganisation?.name || 'Funding Partner'}
</span>
</div>
<h2 className="text-xl font-bold text-white mt-1.5">
{activeProjectModalItem.project.name}
</h2>
{activeProjectModalItem.project.description && (
<p className="text-xs text-slate-400 mt-1">
{activeProjectModalItem.project.description}
</p>
)}
</div>
<button
type="button"
onClick={() => setSelectedProjectItem(null)}
className="p-2 rounded-xl bg-slate-800/80 text-slate-400 hover:text-white cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Simple Step-by-Step Tabs */}
<div className="flex items-center gap-1 px-6 pt-3 border-b border-white/[0.08] bg-slate-900/30 overflow-x-auto">
{(
[
{ 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 => (
<button
key={t.id}
type="button"
onClick={() => setProjectModalTab(t.id)}
className={`px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors whitespace-nowrap cursor-pointer ${
projectModalTab === t.id
? 'border-emerald-400 text-emerald-300'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
{t.label}
</button>
))}
</div>
{/* Modal Body */}
<div className="p-6 max-h-[65vh] overflow-y-auto space-y-6">
{projectModalTab === 'milestones' && (
<div className="space-y-6">
<div className="space-y-3">
<h3 className="text-sm font-bold text-white">Project Milestones</h3>
{activeProjectModalItem.milestones.length === 0 ? (
<p className="text-xs text-slate-400 py-4">
No milestones have been added to this project yet. You can add one below.
</p>
) : (
<div className="space-y-2.5">
{activeProjectModalItem.milestones.map((m, idx) => {
const isVerified = Number(m.status) === ApprovalStatus.Approved;
return (
<div
key={m.id}
className="p-4 rounded-xl bg-slate-900/80 border border-white/[0.07] flex flex-wrap items-center justify-between gap-4"
>
<div className="flex items-center gap-3">
<div
className={`h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold ${
isVerified
? 'bg-emerald-500 text-slate-950'
: 'bg-slate-800 text-emerald-300 border border-emerald-500/30'
}`}
>
{isVerified ? <Check className="w-4 h-4" /> : idx + 1}
</div>
<div>
<p className="text-sm font-semibold text-white">{m.name}</p>
<p className="text-xs text-slate-400">
Due: {formatFullDate(m.dueAt)}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<span
className={`px-2.5 py-1 rounded-full text-[11px] font-semibold ${
isVerified
? 'bg-emerald-500/15 text-emerald-300'
: 'bg-amber-500/15 text-amber-300'
}`}
>
{isVerified ? 'Verified' : 'In Progress'}
</span>
<button
type="button"
onClick={() => {
setSelectedMilestoneForUpload(String(m.id));
setProjectModalTab('evidence');
}}
className="px-3 py-1.5 rounded-lg bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-300 text-xs font-semibold transition-colors cursor-pointer"
>
Upload Proof
</button>
</div>
</div>
);
})}
</div>
)}
</div>
{canAdd('projects_milestones') && (
<div className="p-4 rounded-xl bg-slate-900/50 border border-white/[0.07] space-y-3">
<h4 className="text-xs font-bold uppercase tracking-wider text-emerald-300">
Add a New Milestone
</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<input
type="text"
value={newMilestoneName}
onChange={e => 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"
/>
<input
type="date"
value={newMilestoneDueAt}
onChange={e => setNewMilestoneDueAt(e.target.value)}
className="px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white"
/>
</div>
<button
type="button"
onClick={() => addMilestoneMutation.mutate()}
disabled={addMilestoneMutation.isPending}
className="px-4 py-2 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold text-xs cursor-pointer disabled:opacity-50"
>
{addMilestoneMutation.isPending ? 'Saving...' : 'Add Milestone'}
</button>
</div>
)}
</div>
)}
{projectModalTab === 'evidence' && (
<div className="space-y-6">
{/* Simple Upload Box */}
<div className="p-5 rounded-xl bg-slate-900/60 border border-emerald-500/25 space-y-4">
<div className="flex items-center gap-2 text-emerald-300">
<Upload className="w-4 h-4" />
<h3 className="text-sm font-bold text-white">
Upload Supporting Document or Proof
</h3>
</div>
<p className="text-xs text-slate-400">
Attach invoices, progress reports, photos, or certificates to prove milestone completion.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-slate-300 mb-1">
Which Milestone is this for? (Optional)
</label>
<select
value={selectedMilestoneForUpload}
onChange={e => setSelectedMilestoneForUpload(e.target.value)}
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white"
>
<option value="">General Project Document</option>
{activeProjectModalItem.milestones.map(m => (
<option key={m.id} value={String(m.id)}>
{m.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-slate-300 mb-1">
Document Type
</label>
<select
value={selectedEvidenceType}
onChange={e => setSelectedEvidenceType(Number(e.target.value))}
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white"
>
{Object.entries(EVIDENCE_TYPE_LABELS).map(([val, label]) => (
<option key={val} value={val}>
{label}
</option>
))}
</select>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="file"
onChange={e => setEvidenceFile(e.target.files?.[0] || null)}
className="text-xs text-slate-300 file:mr-3 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-xs file:font-semibold file:bg-emerald-500/15 file:text-emerald-300 hover:file:bg-emerald-500/25"
/>
<button
type="button"
onClick={() => uploadEvidenceMutation.mutate()}
disabled={!evidenceFile || uploadingEvidence}
className="px-5 py-2 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold text-xs disabled:opacity-50 cursor-pointer"
>
{uploadingEvidence ? 'Uploading...' : 'Submit Document'}
</button>
</div>
</div>
{/* Existing Evidence List */}
<div className="space-y-2.5">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Submitted Documents ({activeProjectModalItem.evidence.length})
</h4>
{activeProjectModalItem.evidence.length === 0 ? (
<p className="text-xs text-slate-500 py-3">
No documents uploaded for this project yet.
</p>
) : (
activeProjectModalItem.evidence.map(ev => {
const evType = Number((ev as any).evidenceType ?? ev.type);
const evDate = (ev as any).submittedAt ?? ev.createdAt;
return (
<div
key={ev.id}
className="p-3.5 rounded-xl bg-slate-900/80 border border-white/[0.06] flex items-center justify-between gap-3"
>
<div className="flex items-center gap-3">
<FileText className="w-4 h-4 text-emerald-400 shrink-0" />
<div>
<p className="text-xs font-semibold text-white">
{EVIDENCE_TYPE_LABELS[evType] || 'Document'} (v{ev.version || 1})
</p>
<p className="text-[11px] text-slate-400">
Uploaded {formatFullDate(evDate)}
</p>
</div>
</div>
<button
type="button"
onClick={() => handleOpenSecureFile('evidence', ev.id)}
className="text-xs font-semibold text-emerald-300 hover:text-emerald-200 flex items-center gap-1 cursor-pointer"
>
<span>Open</span>
<ExternalLink className="w-3 h-3" />
</button>
</div>
);
})
)}
</div>
</div>
)}
{projectModalTab === 'payments' && (
<div className="space-y-6">
{/* Request Payment Box */}
<div className="p-5 rounded-xl bg-slate-900/60 border border-emerald-500/25 space-y-4">
<h3 className="text-sm font-bold text-white">Request a Grant Payment Drawdown</h3>
<p className="text-xs text-slate-400">
Select the completed milestone and enter the amount you are requesting.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-slate-300 mb-1">Amount (ZAR)</label>
<input
type="number"
min="0"
step="0.01"
value={paymentAmount}
onChange={e => setPaymentAmount(e.target.value)}
placeholder="e.g. 50000"
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white"
/>
</div>
<div>
<label className="block text-xs text-slate-300 mb-1">Linked Milestone</label>
<select
value={paymentMilestoneId}
onChange={e => setPaymentMilestoneId(e.target.value)}
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white"
>
<option value="">Select milestone (optional)</option>
{activeProjectModalItem.milestones.map(m => (
<option key={m.id} value={String(m.id)}>
{m.name}
</option>
))}
</select>
</div>
</div>
<button
type="button"
onClick={() => requestPaymentMutation.mutate()}
disabled={requestPaymentMutation.isPending}
className="px-5 py-2 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold text-xs cursor-pointer disabled:opacity-50"
>
{requestPaymentMutation.isPending ? 'Submitting...' : 'Submit Payment Request'}
</button>
</div>
{/* Payment History */}
<div className="space-y-2.5">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Payment Requests & Disbursements
</h4>
{activeProjectModalItem.disbursements.length === 0 ? (
<p className="text-xs text-slate-500 py-3">
No payment requests recorded for this project yet.
</p>
) : (
activeProjectModalItem.disbursements.map(d => {
const isApproved = Number(d.status) === ApprovalStatus.Approved;
return (
<div
key={d.id}
className="p-3.5 rounded-xl bg-slate-900/80 border border-white/[0.06] flex items-center justify-between"
>
<div>
<p className="text-sm font-bold text-white">
{formatZAR(d.amount)}
</p>
<p className="text-[11px] text-slate-400">
Requested {formatFullDate(d.createdAt)}
</p>
</div>
<span
className={`px-2.5 py-1 rounded-full text-[11px] font-semibold ${
isApproved
? 'bg-emerald-500/15 text-emerald-300'
: 'bg-amber-500/15 text-amber-300'
}`}
>
{isApproved ? 'Approved & Paid' : 'Pending Review'}
</span>
</div>
);
})
)}
</div>
</div>
)}
{projectModalTab === 'activities' && (
<div className="space-y-6">
<div className="p-5 rounded-xl bg-slate-900/60 border border-white/[0.08] space-y-3">
<h3 className="text-sm font-bold text-white">Post a Quick Progress Update</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<input
type="text"
value={activityTitle}
onChange={e => setActivityTitle(e.target.value)}
placeholder="What did your team accomplish?"
className="sm:col-span-2 px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white"
/>
<input
type="number"
value={activityAmount}
onChange={e => setActivityAmount(e.target.value)}
placeholder="Spend amount (ZAR, optional)"
className="px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white"
/>
</div>
<textarea
rows={2}
value={activityDescription}
onChange={e => setActivityDescription(e.target.value)}
placeholder="Short summary of work completed..."
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white"
/>
<button
type="button"
onClick={() => logActivityMutation.mutate()}
disabled={logActivityMutation.isPending}
className="px-4 py-2 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold text-xs cursor-pointer disabled:opacity-50"
>
{logActivityMutation.isPending ? 'Saving...' : 'Save Progress Update'}
</button>
</div>
<div className="space-y-2.5">
{activeProjectModalItem.activities.map(act => (
<div
key={act.id}
className="p-3.5 rounded-xl bg-slate-900/80 border border-white/[0.06] flex items-center justify-between"
>
<div>
<p className="text-xs font-semibold text-white">{act.title}</p>
{act.description && (
<p className="text-xs text-slate-400 mt-0.5">{act.description}</p>
)}
</div>
{act.amount > 0 && (
<span className="text-xs font-bold text-emerald-300">
{formatZAR(act.amount)}
</span>
)}
</div>
))}
</div>
</div>
)}
{projectModalTab === 'contract' && (
<div className="space-y-4">
{activeProjectModalItem.contract ? (
<div className="p-5 rounded-xl bg-slate-900/80 border border-white/[0.08] space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-emerald-400 font-semibold uppercase">
Grant Contract v{(activeProjectModalItem.contract as any).version || 1}
</p>
<h4 className="text-base font-bold text-white mt-0.5">
Total Value: {formatZAR(activeProjectModalItem.contract.totalValue)}
</h4>
</div>
<button
type="button"
onClick={() =>
handleOpenSecureFile('contract', activeProjectModalItem.contract!.id)
}
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-emerald-300 flex items-center gap-2 cursor-pointer"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>Download Contract</span>
</button>
</div>
{Number(activeProjectModalItem.contract.status) !== ContractStatus.Executed && (
<div className="pt-4 border-t border-white/[0.08] space-y-3">
<p className="text-xs text-slate-300 font-medium">
Upload signed contract copy (optional) & confirm execution:
</p>
<div className="flex flex-wrap items-center gap-3">
<input
type="file"
onChange={e => setContractFile(e.target.files?.[0] || null)}
className="text-xs text-slate-300 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-xs file:bg-slate-800 file:text-slate-200"
/>
<input
type="text"
value={signatureRef}
onChange={e => setSignatureRef(e.target.value)}
placeholder="Your Full Name / Signature Ref"
className="px-3 py-1.5 rounded-lg bg-slate-950 border border-white/10 text-xs text-white"
/>
<button
type="button"
onClick={() =>
signContractMutation.mutate(activeProjectModalItem.contract!)
}
disabled={signContractMutation.isPending}
className="px-4 py-2 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold text-xs cursor-pointer"
>
{signContractMutation.isPending ? 'Submitting...' : 'Sign & Execute Contract'}
</button>
</div>
</div>
)}
</div>
) : (
<p className="text-xs text-slate-400">
No contract linked to this project yet.
</p>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* =====================================================================
MODAL 2: CONTACT FUNDER ("Contact" button on Project Card)
===================================================================== */}
{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 overflow-hidden">
<div className="p-5 border-b border-white/[0.08] flex items-center justify-between">
<div>
<h3 className="text-base font-bold text-white">
Contact {contactTarget.organisation?.name || 'Funding Team'}
</h3>
{contactTarget.projectTitle && (
<p className="text-xs text-slate-400 mt-0.5">{contactTarget.projectTitle}</p>
)}
</div>
<button
type="button"
onClick={() => setContactTarget(null)}
className="p-1.5 rounded-lg text-slate-400 hover:text-white cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSendContactMessage} className="p-5 space-y-4">
<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-emerald-500"
/>
</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-emerald-500"
/>
</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-emerald-500 hover:bg-emerald-400 text-slate-950 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>
)}
{/* =====================================================================
MODAL 3: MESSAGE READER (Clicking an Inbox item)
===================================================================== */}
{selectedNotification && (
<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 overflow-hidden">
<div className="p-5 border-b border-white/[0.08] flex items-center justify-between">
<div>
<h3 className="text-base font-bold text-white">
{selectedNotification.subject || 'Message'}
</h3>
<p className="text-[11px] text-slate-400 mt-0.5">
Received {formatFullDate(selectedNotification.createdAt)}
</p>
</div>
<button
type="button"
onClick={() => setSelectedNotification(null)}
className="p-1.5 rounded-lg text-slate-400 hover:text-white cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 space-y-5">
<p className="text-xs sm:text-sm text-slate-200 leading-relaxed whitespace-pre-wrap">
{selectedNotification.message}
</p>
<div className="flex items-center justify-end gap-3 pt-3 border-t border-white/[0.08]">
<button
type="button"
onClick={() => setSelectedNotification(null)}
className="px-4 py-2 rounded-xl bg-slate-800 text-slate-300 text-xs font-semibold cursor-pointer"
>
Close
</button>
<button
type="button"
onClick={() => {
const subj = selectedNotification.subject || 'Notification';
setSelectedNotification(null);
setContactTarget({
organisation: partnerSummary.primaryFundingOrg,
projectTitle: 'Reply',
});
setMessageSubject(subj.startsWith('Re:') ? subj : `Re: ${subj}`);
setMessageBody('');
}}
className="px-4 py-2 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold text-xs flex items-center gap-1.5 cursor-pointer"
>
<Send className="w-3.5 h-3.5" />
<span>Reply</span>
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
export default DashboardPage;