Files
pfm/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx
T
2026-10-25 08:52:14 +02:00

2214 lines
106 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,
DollarSign,
Paperclip,
Hourglass,
MapPin,
Clock,
Star,
FileText,
Upload,
Send,
ChevronDown,
ShieldCheck,
MessageSquare,
FileCheck2,
X,
Plus,
ExternalLink,
CheckCircle2,
RefreshCw,
Briefcase,
TrendingUp,
Award as AwardIcon,
Info,
Sparkles,
Infinity as InfinityIcon,
} from 'lucide-react';
import { useAuth } from '../../auth/AuthProvider';
import { useRbac } from '../../auth/rbac';
import { useToast } from '../ui/Toast';
import { useInAppNotifications } from '../../hooks/useInAppNotifications';
import {
getApplicantData,
getMilestones,
getProjectEvidence,
getActivities,
getDisbursements,
uploadEvidenceFile,
createActivity,
createDisbursement,
createMilestone,
uploadContractFile,
executeContract,
getContractSecureLink,
getEvidenceSecureLink,
} from '../../services/api';
import {
ApplicantData,
Project,
Milestone,
Evidence,
Activity,
Disbursement,
Contract,
Award,
Organisation,
ApprovalStatus,
ActivityStatus,
ContractStatus,
EvidenceType,
Notification,
NotificationPlatform,
NotificationStatus,
} from '../../types';
import { formatZAR, formatCompactZAR } from '../../lib/formatters';
import { MessageViewer } from '../notifications/MessageViewer';
import { stripMarkdown } from '../../lib/markdownUtils';
interface EnrichedPartnerProject {
project: Project;
award: Award | null;
contract: Contract | null;
fundingOrganisation: Organisation | null;
invitedOrganisation: Organisation | null;
milestones: Milestone[];
evidence: Evidence[];
activities: Activity[];
disbursements: Disbursement[];
}
type NavTab = 'dashboard' | 'messages' | 'contracts' | 'analytics' | 'documents';
type SortOption = 'date_added' | 'grant_amount' | 'days_left' | 'name';
const EVIDENCE_TYPE_LABELS: Record<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();
}
// -----------------------------------------------------------------------------
// SHOWCASE INBOX DATA FOR DASHBOARD PREVIEW
// -----------------------------------------------------------------------------
interface ShowcaseInboxItem {
id: string;
name: string;
snippet: string;
unread: boolean;
avatarBg: string;
avatarInitials: string;
}
const SHOWCASE_INBOX: ShowcaseInboxItem[] = [
{
id: 'inbox-1',
name: 'Mustafa Omar',
snippet: 'Lorem ipsum dolor sit amet, consectetur ..',
unread: true,
avatarBg: 'bg-gradient-to-tr from-blue-500 to-indigo-600',
avatarInitials: 'MO',
},
{
id: 'inbox-2',
name: 'Annie Spratt',
snippet: 'Lorem ipsum dolor sit amet, conseetur adipi..',
unread: false,
avatarBg: 'bg-gradient-to-tr from-cyan-400 to-teal-500',
avatarInitials: 'AS',
},
{
id: 'inbox-3',
name: 'Elie Peragini',
snippet: 'Lorem ipsum dolor amet, consectetur ornare..',
unread: false,
avatarBg: 'bg-gradient-to-tr from-purple-500 to-pink-500',
avatarInitials: 'EP',
},
{
id: 'inbox-4',
name: 'Tim Stundnt',
snippet: 'Lorem ipsum dolor Mauris, sem commodo ...',
unread: true,
avatarBg: 'bg-gradient-to-tr from-amber-500 to-emerald-600',
avatarInitials: 'TS',
},
{
id: 'inbox-5',
name: 'Kerr Lieu',
snippet: 'Lorem ipsum dolor sit amet, consectetur adipi..',
unread: false,
avatarBg: 'bg-gradient-to-tr from-orange-400 to-rose-500',
avatarInitials: 'KL',
},
{
id: 'inbox-6',
name: 'Ozan Safak',
snippet: 'Lorem ipsum dolor sit amet, consectetur onre..',
unread: false,
avatarBg: 'bg-gradient-to-tr from-slate-600 to-slate-800',
avatarInitials: 'OS',
},
{
id: 'inbox-7',
name: 'Katrine Mareu',
snippet: 'Lorem ipsum dolor sit amet, consectetur adipi..',
unread: false,
avatarBg: 'bg-gradient-to-tr from-sky-400 to-blue-600',
avatarInitials: 'KM',
},
{
id: 'inbox-8',
name: 'Deao Saienm',
snippet: 'Lorem ipsum dolor sit amet, consectetur onre..',
unread: true,
avatarBg: 'bg-gradient-to-tr from-amber-600 to-orange-600',
avatarInitials: 'DS',
},
{
id: 'inbox-9',
name: 'Lily Hagon',
snippet: 'Lorem ipsum dolor sit amet, consectetur onre..',
unread: false,
avatarBg: 'bg-gradient-to-tr from-rose-400 to-pink-600',
avatarInitials: 'LH',
},
];
export function DashboardPage() {
const { user, sub, logout } = useAuth();
const { canAdd } = useRbac();
const { success: showSuccessToast, error: showErrorToast, info: showInfoToast } = useToast();
const queryClient = useQueryClient();
const applicantUserId = sub || user?.id || '';
// Navigation & UI state
const [activeTab, setActiveTab] = useState<NavTab>('dashboard');
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<SortOption>('date_added');
const [showBellDropdown, setShowBellDropdown] = useState(false);
const [showUserDropdown, setShowUserDropdown] = 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 hook
const {
inboxNotifications,
unreadCount,
top5Unread,
markAsRead,
markAllAsRead,
sendMessage,
isSending,
} = useInAppNotifications();
// ============================================================================
// PRIMARY API CALL: GetIApplicantDataEndpoint
// ============================================================================
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
const { data: projectDetailsMap = {} } = useQuery<
Record<
string,
{
milestones: Milestone[];
evidence: Evidence[];
activities: Activity[];
disbursements: Disbursement[];
}
>
>({
queryKey: ['partner-project-details', projectIdsKey],
queryFn: async () => {
const entries = partnerSummary.projectEntries;
if (entries.length === 0) return {};
let allDisbursements: Disbursement[] = [];
try {
allDisbursements = await getDisbursements(1, 100);
} catch {
allDisbursements = [];
}
const results = await Promise.all(
entries.map(async ({ project }) => {
const pid = project.id;
const [milestones, evidence, activities] = await Promise.all([
getMilestones(pid).catch(() => [] as Milestone[]),
getProjectEvidence(pid).catch(() => [] as Evidence[]),
getActivities(pid).catch(() => [] as Activity[]),
]);
const projectDisbursements = allDisbursements.filter(
d => String(d.projectId) === String(pid)
);
return [
String(pid),
{
milestones,
evidence,
activities,
disbursements: projectDisbursements,
},
] as const;
})
);
return Object.fromEntries(results);
},
enabled: partnerSummary.projectEntries.length > 0,
staleTime: 30_000,
});
// Combine into EnrichedPartnerProject[]
const enrichedProjects: EnrichedPartnerProject[] = useMemo(() => {
return partnerSummary.projectEntries.map(entry => {
const details = projectDetailsMap[String(entry.project.id)] || {
milestones: [],
evidence: [],
activities: [],
disbursements: [],
};
return {
...entry,
...details,
};
});
}, [partnerSummary.projectEntries, projectDetailsMap]);
// Filter and sort projects
const filteredProjects = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
const list = enrichedProjects.filter(item => {
if (!q) return true;
const titleMatch = (item.project.name || '').toLowerCase().includes(q);
const descMatch = (item.project.description || '').toLowerCase().includes(q);
const funderMatch = (item.fundingOrganisation?.name || '').toLowerCase().includes(q);
return titleMatch || descMatch || funderMatch;
});
return [...list].sort((a, b) => {
if (sortBy === 'name') {
return (a.project.name || '').localeCompare(b.project.name || '');
}
if (sortBy === 'grant_amount') {
const valA = a.contract?.totalValue || a.award?.amount || 0;
const valB = b.contract?.totalValue || b.award?.amount || 0;
return valB - valA;
}
if (sortBy === 'days_left') {
return (
calculateDaysLeft(a.project.endedAt || a.contract?.expiresAt) -
calculateDaysLeft(b.project.endedAt || b.contract?.expiresAt)
);
}
const timeA = new Date(a.project.createdAt || 0).getTime();
const timeB = new Date(b.project.createdAt || 0).getTime();
return timeB - timeA;
});
}, [enrichedProjects, searchQuery, sortBy]);
// Calculate Overview 2x2 metrics
const overviewMetrics = useMemo(() => {
let runningProjects = 0;
let completedProjects = 0;
let pendingPayment = 0;
let totalPayment = 0;
for (const item of enrichedProjects) {
const st = Number(item.project.status);
const allMilestonesVerified =
item.milestones.length > 0 &&
item.milestones.every(m => Number(m.status) === ApprovalStatus.Approved);
if (st === ActivityStatus.Inactive || allMilestonesVerified) {
completedProjects += 1;
} else {
runningProjects += 1;
}
for (const d of item.disbursements) {
const dStatus = Number(d.status);
if (dStatus === ApprovalStatus.Approved) {
totalPayment += Number(d.amount || 0);
} else if (
dStatus === ApprovalStatus.Pending ||
dStatus === ApprovalStatus.Review
) {
pendingPayment += Number(d.amount || 0);
}
}
}
if (totalPayment === 0 && pendingPayment === 0 && enrichedProjects.length > 0) {
const totalAwarded = partnerSummary.awardsList.reduce(
(sum, a) => sum + Number(a.amount || 0),
0
);
const totalContracted = partnerSummary.contractEntries.reduce(
(sum, c) => sum + Number(c.contract.totalValue || 0),
0
);
totalPayment = totalContracted || totalAwarded;
}
return {
runningProjects,
completedProjects,
pendingPayment: pendingPayment > 0 ? formatZAR(pendingPayment) : '$0',
totalPayment: totalPayment > 0 ? formatZAR(totalPayment) : '$0',
};
}, [enrichedProjects, partnerSummary]);
const activeProjectModalItem = useMemo(() => {
if (!selectedProjectItem) return null;
return (
enrichedProjects.find(
x => String(x.project.id) === String(selectedProjectItem.project.id)
) || selectedProjectItem
);
}, [selectedProjectItem, enrichedProjects]);
// ============================================================================
// MUTATIONS
// ============================================================================
const uploadEvidenceMutation = useMutation({
mutationFn: async () => {
if (!activeProjectModalItem || !evidenceFile) {
throw new Error('Please choose a file to upload.');
}
setUploadingEvidence(true);
const formData = new FormData();
formData.append('file', evidenceFile);
formData.append('projectId', String(activeProjectModalItem.project.id));
if (selectedMilestoneForUpload) {
formData.append('milestoneId', selectedMilestoneForUpload);
}
formData.append('evidenceType', String(selectedEvidenceType));
return await uploadEvidenceFile(formData);
},
onSuccess: () => {
setUploadingEvidence(false);
setEvidenceFile(null);
showSuccessToast('Document Submitted', 'Document uploaded and linked to your project.');
queryClient.invalidateQueries({ queryKey: ['partner-project-details'] });
},
onError: (err) => {
setUploadingEvidence(false);
showErrorToast(err);
},
});
const requestPaymentMutation = useMutation({
mutationFn: async () => {
if (!activeProjectModalItem) throw new Error('No project selected.');
const amt = Number(paymentAmount);
if (!amt || amt <= 0) throw new Error('Please enter a valid payment amount greater than zero.');
return await createDisbursement({
projectId: activeProjectModalItem.project.id,
milestoneId: paymentMilestoneId ? paymentMilestoneId : null,
amount: amt,
});
},
onSuccess: () => {
setPaymentAmount('');
setPaymentMilestoneId('');
showSuccessToast('Payment Requested', 'Your payment request has been submitted for review.');
queryClient.invalidateQueries({ queryKey: ['partner-project-details'] });
},
onError: (err) => showErrorToast(err),
});
const logActivityMutation = useMutation({
mutationFn: async () => {
if (!activeProjectModalItem) throw new Error('No project selected.');
if (!activityTitle.trim()) throw new Error('Please enter a short title for this update.');
return await createActivity({
projectId: activeProjectModalItem.project.id,
category: activityCategory,
amount: Number(activityAmount) || 0,
title: activityTitle.trim(),
description: activityDescription.trim(),
performedAt: new Date().toISOString(),
});
},
onSuccess: () => {
setActivityTitle('');
setActivityAmount('');
setActivityDescription('');
showSuccessToast('Update Saved', 'Project progress update logged.');
queryClient.invalidateQueries({ queryKey: ['partner-project-details'] });
},
onError: (err) => showErrorToast(err),
});
const addMilestoneMutation = useMutation({
mutationFn: async () => {
if (!activeProjectModalItem) throw new Error('No project selected.');
if (!newMilestoneName.trim() || !newMilestoneDueAt) {
throw new Error('Please provide both a milestone name and due date.');
}
return await createMilestone({
projectId: activeProjectModalItem.project.id,
name: newMilestoneName.trim(),
dueAt: newMilestoneDueAt,
priority: 1,
});
},
onSuccess: () => {
setNewMilestoneName('');
setNewMilestoneDueAt('');
showSuccessToast('Milestone Added', 'Milestone added to your project schedule.');
queryClient.invalidateQueries({ queryKey: ['partner-project-details'] });
},
onError: (err) => showErrorToast(err),
});
const signContractMutation = useMutation({
mutationFn: async (contract: Contract) => {
if (contractFile) {
await uploadContractFile(contract.id, contractFile);
}
await executeContract({
contractId: contract.id,
externalSignatureId: signatureRef.trim() || `SIG-${Date.now()}`,
signedDocumentUrl: contract.signedDocumentUrl || `s3://contracts/${contract.id}/signed.pdf`,
});
},
onSuccess: () => {
setContractFile(null);
setSignatureRef('');
showSuccessToast('Agreement Executed', 'Contract signed and submitted.');
queryClient.invalidateQueries({ queryKey: ['partner-applicant-data'] });
},
onError: (err) => showErrorToast(err),
});
const handleOpenSecureFile = async (type: 'contract' | 'evidence', id: string | number) => {
try {
const link =
type === 'contract'
? await getContractSecureLink(id)
: await getEvidenceSecureLink(id);
if (link) {
window.open(link, '_blank', 'noopener,noreferrer');
} else {
showInfoToast('File Unavailable', 'No file URL is attached to this record yet.');
}
} catch (err) {
showErrorToast(err);
}
};
const handleSendContactMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!messageSubject.trim() || !messageBody.trim()) {
showInfoToast('Missing Fields', 'Please enter both a subject and message.');
return;
}
try {
const targetOrg = contactTarget?.organisation || partnerSummary.primaryFundingOrg;
const recipientEmail =
contactTarget?.recipientEmail ||
targetOrg?.email ||
'support@pfm.gov.za';
const recipientId =
contactTarget?.recipientId ||
targetOrg?.userId ||
applicantUserId;
await sendMessage({
recipientId: String(recipientId),
recipientEmail,
recipientName: targetOrg?.name || 'Funding Administrator',
subject: messageSubject.trim(),
message: messageBody.trim(),
platform: NotificationPlatform.InApp,
organisationId: targetOrg?.id || 1,
});
showSuccessToast('Message Sent', 'Your message has been sent to the funding team.');
setMessageSubject('');
setMessageBody('');
setContactTarget(null);
} catch (err) {
showErrorToast(err);
}
};
const openContactModalForProject = (item: EnrichedPartnerProject) => {
setContactTarget({
organisation: item.fundingOrganisation || partnerSummary.primaryFundingOrg,
projectTitle: item.project.name || 'Grant Project',
recipientEmail: item.fundingOrganisation?.email || undefined,
recipientId: item.fundingOrganisation?.userId || undefined,
});
setMessageSubject(`Enquiry regarding ${item.project.name || 'Project'}`);
setMessageBody('');
};
// Nav Tabs
const navTabs: { id: NavTab; label: string }[] = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'messages', label: 'Messages' },
{ id: 'contracts', label: 'Awards' },
{ id: 'analytics', label: 'Analytics' },
{ id: 'documents', label: 'Earnings' },
];
// Combined Inbox items: real unread notifications first, followed by showcase mock items
const combinedInbox = useMemo(() => {
const list: ShowcaseInboxItem[] = [];
if (inboxNotifications && inboxNotifications.length > 0) {
for (const n of inboxNotifications.slice(0, 9)) {
const isUnread = Number(n.status) !== NotificationStatus.Read;
const sender =
n.destination?.split('|')[0]?.replace('To:', '').trim() ||
partnerSummary.primaryFundingOrg?.name ||
'Programme Officer';
list.push({
id: String(n.id),
name: sender,
snippet: stripMarkdown(n.message || n.subject || 'New update received'),
unread: isUnread,
avatarBg: 'bg-gradient-to-tr from-emerald-500 to-teal-600',
avatarInitials: getInitials(sender),
});
}
}
// Fill with showcase items to maintain layout integrity
for (const item of SHOWCASE_INBOX) {
if (!list.some(x => x.name.toLowerCase() === item.name.toLowerCase())) {
list.push(item);
}
if (list.length >= 9) break;
}
return list;
}, [inboxNotifications, partnerSummary]);
return (
<div className="min-h-screen w-full bg-[#0F0E17] text-[#F1F1F6] flex flex-col selection:bg-[#8B5CF6]/30 selection:text-[#F1F1F6]">
{/* =====================================================================
TOP NAVIGATION BAR — SNAPPED TO TOP, 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">
{/* Flat black infinity icon rotated with top lobe pointing to 2 o'clock */}
<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 with bottom indicator on active */}
<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 Pill + Refresh + Notification Bell + Profile */}
<div className="flex items-center gap-3 sm:gap-4 ml-auto">
{/* Search Input: 280px x 38px */}
<div className="relative w-44 sm:w-56 lg:w-[280px] h-[38px]">
<Search className="w-3.5 h-3.5 text-[#9A95B0] 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 ...."
className="w-full h-full pl-9 pr-8 rounded-[8px] bg-white/[0.05] border border-white/[0.08] text-xs text-[#F1F1F6] placeholder:text-[#9A95B0]/70 focus:outline-none focus:border-[#8B5CF6] transition-all"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#9A95B0] hover:text-[#F1F1F6]"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{/* Refresh Data Button */}
<button
type="button"
onClick={() => refetchApplicantData()}
title="Refresh Partner Data"
className="p-2 text-[#9A95B0] hover:text-[#F1F1F6] transition-colors cursor-pointer"
>
<RefreshCw className={`w-4 h-4 ${isRefreshingApplicantData ? 'animate-spin text-[#8B5CF6]' : ''}`} />
</button>
{/* Notification Bell */}
<div className="relative">
<button
type="button"
onClick={() => setShowBellDropdown(prev => !prev)}
className="relative p-2 text-[#9A95B0] hover:text-[#F1F1F6] transition-colors cursor-pointer"
aria-label="Notifications"
>
<Bell className="w-4 h-4" />
{unreadCount > 0 && (
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-[#EF4444] ring-2 ring-[#0F0E17]" />
)}
</button>
{showBellDropdown && (
<div className="absolute right-0 mt-3 w-80 sm:w-96 rounded-2xl bg-[#181622] border border-white/[0.08] shadow-2xl p-4 z-50 text-[#F1F1F6]">
<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-[#F1F1F6]">Notifications</span>
{unreadCount > 0 && (
<span className="px-2 py-0.5 text-[11px] font-semibold rounded-full bg-[#8B5CF6]/20 text-[#8B5CF6]">
{unreadCount} unread
</span>
)}
</div>
{unreadCount > 0 && (
<button
type="button"
onClick={() => markAllAsRead()}
className="text-xs text-[#8B5CF6] hover:text-[#8B5CF6]/80 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-6 text-center text-xs text-[#9A95B0]">
You are all caught up! No unread 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-[#EF4444] mt-1.5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-[#F1F1F6] truncate">
{item.subject || 'Partner Notification'}
</p>
<p className="text-xs text-[#9A95B0] 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-[#8B5CF6] hover:text-white bg-[#8B5CF6]/10 hover:bg-[#8B5CF6]/20 rounded-xl transition-colors cursor-pointer"
>
See All Messages
</button>
</div>
)}
</div>
{/* Profile Avatar + Name + Dropdown */}
<div className="relative">
<button
type="button"
onClick={() => setShowUserDropdown(prev => !prev)}
className="flex items-center gap-2 text-left group cursor-pointer focus:outline-none"
>
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-amber-400 via-rose-400 to-[#8B5CF6] p-0.5 flex items-center justify-center shrink-0">
<div className="w-full h-full rounded-full bg-[#181622] flex items-center justify-center text-[11px] font-bold text-amber-200">
{getInitials(user?.name || 'Samia Afrin Mithila')}
</div>
</div>
<span className="hidden sm:inline-block text-xs font-semibold text-[#F1F1F6] group-hover:text-white transition-colors">
{user?.name || 'Samia Afrin Mithila'}
</span>
<ChevronDown className="w-3.5 h-3.5 text-[#9A95B0] group-hover:text-[#F1F1F6] transition-colors" />
</button>
{showUserDropdown && (
<div className="absolute right-0 mt-3 w-56 rounded-2xl bg-[#181622] border border-white/[0.08] shadow-2xl p-2 z-50 text-[#F1F1F6]">
<div className="px-3 py-2 border-b border-white/[0.06]">
<p className="text-xs font-bold text-[#F1F1F6] truncate">{user?.name || 'Samia Afrin Mithila'}</p>
<p className="text-[11px] text-[#9A95B0] truncate">{user?.email || 'partner@grantee.org'}</p>
{partnerSummary.primaryInvitedOrg && (
<p className="text-[10px] text-[#8B5CF6] mt-1 truncate">
{partnerSummary.primaryInvitedOrg.name}
</p>
)}
</div>
<button
type="button"
onClick={() => {
setShowUserDropdown(false);
logout();
}}
className="w-full mt-1 px-3 py-2 text-left text-xs text-rose-300 hover:bg-rose-500/10 rounded-xl flex items-center gap-2 transition-colors cursor-pointer"
>
<LogOut className="w-3.5 h-3.5" />
<span>Sign Out</span>
</button>
</div>
)}
</div>
</div>
</div>
{/* Mobile Navigation bar for smaller screens */}
<div className="flex md:hidden items-center gap-2 px-6 pb-2.5 border-t border-white/[0.06] overflow-x-auto bg-[#0F0E17] w-full">
{navTabs.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-[#8B5CF6]/20 text-[#8B5CF6] border border-[#8B5CF6]/30 font-bold'
: 'text-[#9A95B0]'
}`}
>
{tab.label}
</button>
))}
</div>
</header>
{/* =====================================================================
MAIN BODY SECTION (max-w-[1440px] centered with outer ambient margin)
===================================================================== */}
<main className="w-full max-w-[1440px] mx-auto px-6 py-6 sm:py-8 flex-1">
<div>
{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-[#8B5CF6] border-t-transparent" />
<div>
<p className="text-base font-semibold text-[#F1F1F6]">Loading Workspace...</p>
<p className="text-xs text-[#9A95B0] mt-1">Retrieving ongoing projects and milestones.</p>
</div>
</div>
) : activeTab === 'dashboard' ? (
/* =================================================================
DASHBOARD: ASYMMETRICAL 2-COLUMN GRID (1fr 340px, gap 24px)
================================================================= */
<div className="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-6 items-start">
{/* ===============================================================
LEFT MAIN SECTION: ONGOING PROJECTS (~70% width)
=============================================================== */}
<div className="space-y-5">
{/* Title + Sort Row: 20px / 28px / font-weight 700 / #F1F1F6 */}
<div className="flex items-center justify-between">
<h2 className="text-[20px] leading-[28px] font-bold text-[#F1F1F6]">
Ongoing Projects
</h2>
{/* Sort selector matching "Sort by date added v" */}
<div className="relative">
<select
value={sortBy}
onChange={e => setSortBy(e.target.value as SortOption)}
className="appearance-none bg-transparent hover:text-[#F1F1F6] pr-6 py-1 text-xs font-medium text-[#9A95B0] 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 spend / grant</option>
<option value="days_left" className="bg-[#181622] text-[#F1F1F6]">Sort by days left</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-0 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
</div>
{/* Stacked Project Cards */}
<div className="space-y-4">
{filteredProjects.length > 0 ? (
filteredProjects.map((item, pIdx) => {
const { project, award, contract, fundingOrganisation, milestones, evidence, disbursements } = item;
const grantAmount = contract?.totalValue || award?.amount || 0;
const disbursedAmount = disbursements
.filter(d => Number(d.status) === ApprovalStatus.Approved)
.reduce((sum, d) => sum + Number(d.amount || 0), 0);
const daysLeft = calculateDaysLeft(project.endedAt || contract?.expiresAt);
const docsSubmitted = evidence.length;
const sortedMilestones = [...milestones].sort(
(a, b) => new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime()
);
const stepperNodes = [
{
id: 'start',
label: 'Project start',
date: formatShortDate(project.startedAt || contract?.effectiveAt || project.createdAt),
type: 'done' as const,
},
...[0, 1, 2, 3].map(idx => {
const m = sortedMilestones[idx];
if (!m) {
return {
id: `m-placeholder-${idx}`,
label: `Milestone ${idx + 1}`,
date: 'Planned',
type: 'upcoming' as const,
};
}
const mStatus = Number(m.status);
const isDone = mStatus === ApprovalStatus.Approved;
const isCurrent = !isDone && (mStatus === ApprovalStatus.Review || idx === 0 || Number(sortedMilestones[idx - 1]?.status) === ApprovalStatus.Approved);
return {
id: String(m.id),
label: m.name || `Milestone ${idx + 1}`,
date: formatShortDate(m.dueAt),
type: isDone ? ('done' as const) : isCurrent ? ('current' as const) : ('upcoming' as const),
};
}),
];
const leadName = fundingOrganisation?.name || partnerSummary.primaryFundingOrg?.name || 'Project Officer';
const leadRole = 'Programme Manager';
const avatarColors = [
'bg-gradient-to-tr from-sky-400 to-blue-600',
'bg-gradient-to-tr from-amber-400 to-[#F97316]',
'bg-gradient-to-tr from-[#8B5CF6] to-indigo-600',
];
return (
<div
key={project.id}
className="rounded-[16px] bg-[#181622] border border-white/[0.06] p-6 shadow-sm hover:border-white/[0.12] transition-all"
>
<div className="flex flex-col lg:flex-row gap-6 justify-between">
{/* Left Sub-portion (~70%): Title, Description, 4 Metrics, Horizontal Timeline */}
<div className="flex-1 flex flex-col justify-between gap-5 min-w-0">
<div>
<h3 className="text-[18px] leading-[24px] font-semibold text-[#F1F1F6]">
{project.name || 'Funded Project'}
</h3>
<p className="text-[14px] leading-[22px] font-normal text-[#9A95B0] line-clamp-2 mt-1.5 max-w-2xl">
{project.description || 'Funding award execution, deliverable submissions, and post-fund milestone tracking.'}
</p>
</div>
{/* 4 Metrics in a horizontal inline row: 13px font-weight 500 #D1CBDD */}
<div className="flex flex-wrap items-center gap-4 sm:gap-6 py-1">
<div className="flex items-center gap-1.5 text-[13px] font-medium text-[#D1CBDD]">
<span className="p-1 rounded-md bg-emerald-500/10 text-emerald-400 flex items-center justify-center">
<Hourglass className="w-3.5 h-3.5" />
</span>
<span className="font-semibold text-[#F1F1F6]">{formatCompactZAR(grantAmount)}</span>
<span className="text-[#9A95B0]">Award</span>
</div>
<div className="flex items-center gap-1.5 text-[13px] font-medium text-[#D1CBDD]">
<span className="p-1 rounded-md bg-[#F97316]/10 text-[#F97316] flex items-center justify-center">
<DollarSign className="w-3.5 h-3.5" />
</span>
<span className="font-semibold text-[#F1F1F6]">{formatCompactZAR(disbursedAmount)}</span>
<span className="text-[#9A95B0]">Spend</span>
</div>
<div className="flex items-center gap-1.5 text-[13px] font-medium text-[#D1CBDD]">
<span className="p-1 rounded-md bg-amber-500/10 text-amber-400 flex items-center justify-center">
<Calendar className="w-3.5 h-3.5" />
</span>
<span className="font-semibold text-[#F1F1F6]">{daysLeft}</span>
<span className="text-[#9A95B0]">Days left</span>
</div>
<div className="flex items-center gap-1.5 text-[13px] font-medium text-[#D1CBDD]">
<span className="p-1 rounded-md bg-cyan-500/10 text-cyan-400 flex items-center justify-center">
<Paperclip className="w-3.5 h-3.5" />
</span>
<span className="font-semibold text-[#F1F1F6]">{docsSubmitted}</span>
<span className="text-[#9A95B0]">Docs submitted</span>
</div>
</div>
{/* Horizontal Timeline Stepper: height 2px, 10px nodes */}
<div className="pt-2 overflow-x-auto">
<div className="min-w-[460px] flex items-start justify-between relative">
{stepperNodes.map((step, sIdx) => {
const isLast = sIdx === stepperNodes.length - 1;
const isDotted = sIdx === 2; // Exact connector style from reference
return (
<div key={step.id} className="flex-1 flex flex-col items-start relative">
<div className="w-full flex items-center">
{step.type === 'done' ? (
<div className="w-2.5 h-2.5 rounded-full bg-[#8B5CF6] ring-4 ring-[#8B5CF6]/20 shrink-0 z-10 shadow-sm" />
) : step.type === 'current' ? (
<div className="w-2.5 h-2.5 rounded-full bg-[#8B5CF6] ring-4 ring-[#8B5CF6]/30 animate-pulse shrink-0 z-10" />
) : (
<div className="w-2.5 h-2.5 rounded-full border border-white/30 bg-[#181622] shrink-0 z-10" />
)}
{!isLast && (
<div
className={`flex-1 mx-2 ${
isDotted
? 'border-t-2 border-dotted border-white/20'
: step.type === 'done'
? 'h-[2px] bg-[#8B5CF6]'
: 'h-[2px] bg-white/10'
}`}
/>
)}
</div>
<div className="mt-2.5 pr-2">
<p className="text-[11px] font-medium text-[#F1F1F6] truncate max-w-[105px]">
{step.label}
</p>
<p className="text-[10px] text-[#9A95B0] mt-0.5">
{step.date}
</p>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Right Sub-portion (~30% / 220px): Contact Lead, 3 Meta Rows, 2 Stacked Action Buttons */}
<div className="w-full lg:w-[220px] shrink-0 lg:pl-6 lg:border-l lg:border-white/[0.06] flex flex-col justify-between gap-4 pt-4 lg:pt-0 border-t lg:border-t-0 border-white/[0.06]">
{/* Contact Person */}
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-[14px] font-semibold text-[#F1F1F6] leading-tight truncate max-w-[140px]">
{leadName}
</h4>
<p className="text-[12px] text-[#9A95B0] mt-0.5">{leadRole}</p>
</div>
<div className="relative shrink-0">
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-xs ${avatarColors[pIdx % 3]} text-white shadow-sm`}>
{getInitials(leadName)}
</div>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-emerald-400 ring-2 ring-[#181622]" />
</div>
</div>
{/* 3 Meta Rows: Location, Time, Star Rating */}
<div className="space-y-1.5 text-[13px] font-medium text-[#D1CBDD]">
<div className="flex items-center gap-2">
<MapPin className="w-3.5 h-3.5 text-[#9A95B0] shrink-0" />
<span className="truncate">Gauteng, South Africa</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-3.5 h-3.5 text-[#9A95B0] shrink-0" />
<span>01:23 pm</span>
</div>
<div className="flex items-center gap-2">
<Star className="w-3.5 h-3.5 text-amber-400 fill-amber-400 shrink-0" />
<span>4.8/5</span>
</div>
</div>
{/* 2 Stacked Buttons: Contact (Outline) & View Details (Soft Tinted) */}
<div className="space-y-2">
<button
type="button"
onClick={() => openContactModalForProject(item)}
className="w-full py-2 px-3 rounded-xl border border-white/10 hover:border-white/20 hover:bg-white/[0.04] text-[#F1F1F6] text-xs font-semibold transition-all cursor-pointer"
>
Contact
</button>
<button
type="button"
onClick={() => {
setSelectedProjectItem(item);
setProjectModalTab('milestones');
}}
className="w-full py-2 px-3 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-[#F1F1F6] text-xs font-semibold transition-all cursor-pointer shadow-sm"
>
View Details
</button>
</div>
</div>
</div>
</div>
);
})
) : (
/* Friendly Empty State with Refresh Button & Informative Section */
<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">
{/* Ambient background glow */}
<div className="absolute -top-24 left-1/2 -translate-x-1/2 w-80 h-80 rounded-full bg-[#8B5CF6]/10 blur-[100px] pointer-events-none" />
{/* Friendly Icon Badge */}
<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>
<span className="absolute -bottom-1 -right-1 flex h-3.5 w-3.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#8B5CF6] opacity-75" />
<span className="relative inline-flex rounded-full h-3.5 w-3.5 bg-[#8B5CF6]" />
</span>
</div>
{/* Friendly Heading: 20px font-bold #F1F1F6 */}
<h3 className="text-[20px] font-bold text-[#F1F1F6] tracking-tight">
{searchQuery ? 'No Matching Projects Found' : 'No Active Projects Assigned Yet'}
</h3>
{/* Friendly Message: 14px / 22px / #9A95B0 */}
<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>
{/* Refresh / Action Button */}
<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>
) : null}
<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 active:scale-95 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>
{/* Elegantly placed informative text below all that */}
<div className="mt-10 w-full max-w-xl text-left border-t border-white/[0.06] pt-8">
<div className="flex items-center justify-between gap-2 mb-4">
<div className="flex items-center gap-2 text-[#8B5CF6]">
<Info className="w-4 h-4" />
<span className="text-[11px] font-semibold uppercase tracking-[0.05em]">How Project Activation Works</span>
</div>
<span className="text-[11px] text-[#9A95B0] font-medium">Post-Fund Lifecycle</span>
</div>
<div className="space-y-3">
<div className="p-4 rounded-[14px] bg-[#0F0E17]/90 border border-white/[0.05] hover:border-white/[0.1] transition-colors flex items-start gap-3.5">
<div className="h-6 w-6 rounded-lg bg-[#8B5CF6]/20 border border-[#8B5CF6]/30 text-[#8B5CF6] flex items-center justify-center text-xs font-bold shrink-0 mt-0.5">
1
</div>
<div className="min-w-0">
<p className="text-[13px] font-semibold text-[#F1F1F6]">Award Handover & Formal Agreement</p>
<p className="text-[12px] leading-[18px] text-[#9A95B0] mt-0.5">
Following sanction by the approval committee, your funding organisation registers the award. A legally binding grant agreement is automatically drafted for your review and digital execution.
</p>
</div>
</div>
<div className="p-4 rounded-[14px] bg-[#0F0E17]/90 border border-white/[0.05] hover:border-white/[0.1] transition-colors flex items-start gap-3.5">
<div className="h-6 w-6 rounded-lg bg-[#8B5CF6]/20 border border-[#8B5CF6]/30 text-[#8B5CF6] flex items-center justify-center text-xs font-bold shrink-0 mt-0.5">
2
</div>
<div className="min-w-0">
<p className="text-[13px] font-semibold text-[#F1F1F6]">Milestones & Evidence Submission</p>
<p className="text-[12px] leading-[18px] text-[#9A95B0] mt-0.5">
Once the project is active, your work breakdown structure, deliverable due dates, and mandatory evidence requirements appear here so you can easily upload progress reports, invoices, and certificates.
</p>
</div>
</div>
<div className="p-4 rounded-[14px] bg-[#0F0E17]/90 border border-white/[0.05] hover:border-white/[0.1] transition-colors flex items-start gap-3.5">
<div className="h-6 w-6 rounded-lg bg-[#8B5CF6]/20 border border-[#8B5CF6]/30 text-[#8B5CF6] flex items-center justify-center text-xs font-bold shrink-0 mt-0.5">
3
</div>
<div className="min-w-0">
<p className="text-[13px] font-semibold text-[#F1F1F6]">Payment Requests & Controlled Tranches</p>
<p className="text-[12px] leading-[18px] text-[#9A95B0] mt-0.5">
Funds are released safely against verified milestone deliverables and satisfied conditions precedent. You can initiate drawdown requests directly from your project workspace.
</p>
</div>
</div>
<div className="p-3.5 rounded-xl bg-[#8B5CF6]/10 border border-[#8B5CF6]/20 flex items-center justify-between gap-3 text-xs text-[#8B5CF6]">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-[#8B5CF6] shrink-0" />
<span>Need status clarification? Contact your assigned Programme Officer.</span>
</div>
<button
type="button"
onClick={() => setActiveTab('messages')}
className="text-xs font-semibold text-[#F1F1F6] underline hover:text-[#8B5CF6] cursor-pointer shrink-0"
>
Open Messages
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
{/* ===============================================================
RIGHT COLUMN: SIDEBAR WIDGETS (Overview & Inbox, 340px)
=============================================================== */}
<div className="w-full lg:w-[340px] space-y-5">
{/* 1. OVERVIEW CARD (2x2 Grid with vertical divider) */}
<div className="rounded-[16px] bg-[#181622] border border-white/[0.06] p-6 shadow-sm">
<h3 className="text-[20px] leading-[28px] font-bold text-[#F1F1F6] mb-5">
Overview
</h3>
<div className="grid grid-cols-2 gap-4 relative">
{/* Subtle vertical divider between the 2 columns */}
<div className="absolute top-1 bottom-1 left-1/2 -translate-x-1/2 w-px bg-white/[0.06]" />
{/* Left Column: Running Project & Completed Project */}
<div className="space-y-6 pr-3">
<div>
<p className="text-[24px] font-bold text-[#F1F1F6] leading-none">
{overviewMetrics.runningProjects}
</p>
<p className="text-[12px] font-normal text-[#9A95B0] mt-1.5">Running Project</p>
</div>
<div>
<p className="text-[24px] font-bold text-[#F1F1F6] leading-none">
{overviewMetrics.completedProjects}
</p>
<p className="text-[12px] font-normal text-[#9A95B0] mt-1.5">Completed Project</p>
</div>
</div>
{/* Right Column: Have to Pay & Total Payment */}
<div className="space-y-6 pl-4 sm:pl-6">
<div>
<p className="text-[24px] font-bold text-[#F1F1F6] leading-none">
{overviewMetrics.pendingPayment}
</p>
<p className="text-[12px] font-normal text-[#9A95B0] mt-1.5">Have to Pay</p>
</div>
<div>
<p className="text-[24px] font-bold text-[#F1F1F6] leading-none">
{overviewMetrics.totalPayment}
</p>
<p className="text-[12px] font-normal text-[#9A95B0] mt-1.5">Total Payment</p>
</div>
</div>
</div>
</div>
{/* 2. INBOX CARD (Matching Dribbble design: Inbox + badge, See All, and clean avatar list) */}
<div className="rounded-[16px] bg-[#181622] border border-white/[0.06] p-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="text-[20px] leading-[28px] font-bold text-[#F1F1F6]">Inbox</h3>
<span className="h-5 min-w-[20px] px-1.5 rounded-full bg-[#EF4444] text-white text-[11px] font-bold flex items-center justify-center">
{unreadCount > 0 ? unreadCount : 3}
</span>
</div>
<button
type="button"
onClick={() => setActiveTab('messages')}
className="text-[12px] font-medium text-[#9A95B0] hover:text-[#F1F1F6] transition-colors cursor-pointer"
>
See All
</button>
</div>
{/* List of Contacts & Messages: Compact vertical list */}
<div className="space-y-1 divide-y divide-white/[0.04]">
{combinedInbox.map((item) => (
<div
key={item.id}
onClick={() => {
const realMsg = inboxNotifications.find(x => String(x.id) === item.id);
if (realMsg) {
markAsRead(realMsg.id);
setSelectedNotification(realMsg);
} else {
// Demo item: open contact popup to reply
setContactTarget({
organisation: partnerSummary.primaryFundingOrg,
projectTitle: item.name,
});
setMessageSubject(`Re: Message from ${item.name}`);
setMessageBody('');
}
}}
className="flex items-center gap-3 py-2 px-1 hover:bg-white/[0.02] rounded-xl transition-colors cursor-pointer"
>
<div className={`w-9 h-9 rounded-full shrink-0 flex items-center justify-center font-bold text-xs ${item.avatarBg} text-white shadow-sm`}>
{item.avatarInitials}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-1.5">
<span className="text-[13px] font-semibold text-[#F1F1F6] truncate group-hover:text-[#8B5CF6] transition-colors">
{item.name}
</span>
{item.unread && (
<span className="w-1.5 h-1.5 rounded-full bg-[#EF4444] shrink-0" />
)}
</div>
<p className="text-[12px] font-normal text-[#9A95B0] truncate mt-0.5 leading-tight">
{item.snippet}
</p>
</div>
</div>
))}
</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
================================================================= */
<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">
Performance metrics, milestone velocity, and financial drawdown burn rates.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
<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">+18% vs target</span>
</div>
<p className="text-2xl font-bold text-[#F1F1F6]">92.4%</p>
<p className="text-xs text-[#9A95B0] mt-1">Milestone Completion Rate</p>
</div>
<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]">On Track</span>
</div>
<p className="text-2xl font-bold text-[#F1F1F6]">{overviewMetrics.runningProjects} Active</p>
<p className="text-xs text-[#9A95B0] mt-1">Funded Projects in Execution</p>
</div>
<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]">100%</p>
<p className="text-xs text-[#9A95B0] mt-1">Compliance & Evidence Health</p>
</div>
</div>
</div>
) : (
/* =================================================================
DOCUMENTS & EARNINGS 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, payment requests, 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. Open any project on your Dashboard and click "View Details" to submit 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>
)}
</div>
</main>
{/* =====================================================================
MODAL 1: PROJECT WORKSPACE (From "View Details" Button)
===================================================================== */}
{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">
<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-indigo-500/20 text-indigo-300 border border-indigo-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>
<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-indigo-400 text-indigo-300'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
{t.label}
</button>
))}
</div>
<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-indigo-300 border border-indigo-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-indigo-500/15 hover:bg-indigo-500/25 text-indigo-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-indigo-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-indigo-500 hover:bg-indigo-400 text-white font-semibold text-xs cursor-pointer disabled:opacity-50"
>
{addMilestoneMutation.isPending ? 'Saving...' : 'Add Milestone'}
</button>
</div>
)}
</div>
)}
{projectModalTab === 'evidence' && (
<div className="space-y-6">
<div className="p-5 rounded-xl bg-slate-900/60 border border-indigo-500/25 space-y-4">
<div className="flex items-center gap-2 text-indigo-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">
Type of Document
</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(([k, label]) => (
<option key={k} value={k}>
{label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs text-slate-300 mb-1">Select File</label>
<input
type="file"
onChange={e => setEvidenceFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-slate-300 file:mr-3 file:py-1 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-indigo-500/20 file:text-indigo-300"
/>
</div>
<button
type="button"
onClick={() => uploadEvidenceMutation.mutate()}
disabled={uploadingEvidence || !evidenceFile}
className="px-5 py-2.5 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"
>
<Upload className="w-3.5 h-3.5" />
<span>{uploadingEvidence ? 'Uploading...' : 'Submit Document'}</span>
</button>
</div>
</div>
)}
{projectModalTab === 'payments' && (
<div className="space-y-6">
<div className="p-5 rounded-xl bg-slate-900/60 border border-white/[0.08] space-y-4">
<h3 className="text-sm font-bold text-white">Request a Grant Payment Tranche</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-slate-300 mb-1">Requested Amount (ZAR)</label>
<input
type="number"
value={paymentAmount}
onChange={e => setPaymentAmount(e.target.value)}
placeholder="e.g. 150000"
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white"
/>
</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</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 || !paymentAmount}
className="px-5 py-2.5 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"
>
<DollarSign className="w-3.5 h-3.5" />
<span>{requestPaymentMutation.isPending ? 'Submitting...' : 'Submit Payment Request'}</span>
</button>
</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">Log Project Activity Update</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input
type="text"
value={activityTitle}
onChange={e => setActivityTitle(e.target.value)}
placeholder="Activity title"
className="px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white"
/>
<input
type="number"
value={activityAmount}
onChange={e => setActivityAmount(e.target.value)}
placeholder="Cost incurred (optional)"
className="px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white"
/>
</div>
<textarea
rows={3}
value={activityDescription}
onChange={e => setActivityDescription(e.target.value)}
placeholder="Brief description of work done..."
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 || !activityTitle.trim()}
className="px-4 py-2 rounded-xl bg-indigo-500 hover:bg-indigo-400 text-white font-semibold text-xs cursor-pointer disabled:opacity-50"
>
{logActivityMutation.isPending ? 'Saving...' : 'Save Update'}
</button>
</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-3">
<h3 className="text-sm font-bold text-white">Binding Grant Agreement</h3>
<p className="text-xs text-slate-400">
Contract Value: {formatZAR(activeProjectModalItem.contract.totalValue || 0)}
</p>
<button
type="button"
onClick={() => handleOpenSecureFile('contract', activeProjectModalItem.contract!.id)}
className="px-4 py-2 rounded-xl bg-slate-800 text-indigo-300 text-xs font-semibold flex items-center gap-2 cursor-pointer"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>View Contract Agreement Document</span>
</button>
</div>
) : (
<p className="text-xs text-slate-400 py-4">No formal agreement linked yet.</p>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* =====================================================================
MODAL 2: CONTACT / 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>
)}
{/* =====================================================================
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 animate-in fade-in duration-200">
<div className="w-full max-w-3xl xl:max-w-4xl rounded-2xl bg-[#0d1322] border border-white/10 shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="p-5 border-b border-white/[0.08] flex items-center justify-between bg-[#0b101d] shrink-0">
<div className="min-w-0 pr-4">
<h3 className="text-base font-bold text-white truncate">
{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 hover:bg-white/5 transition shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-4 sm:p-5 overflow-y-auto overflow-x-hidden flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]">
<MessageViewer
content={selectedNotification.message || ''}
platform={selectedNotification.platform}
subject={selectedNotification.subject}
/>
</div>
<div className="px-5 py-3.5 border-t border-white/[0.08] bg-[#0b101d] flex items-center justify-end gap-3 shrink-0">
<button
type="button"
onClick={() => setSelectedNotification(null)}
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold cursor-pointer transition"
>
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-indigo-500 hover:bg-indigo-400 text-white font-semibold text-xs flex items-center gap-1.5 cursor-pointer transition shadow-lg shadow-indigo-500/20"
>
<Send className="w-3.5 h-3.5" />
<span>Reply</span>
</button>
</div>
</div>
</div>
)}
</div>
);
}
export default DashboardPage;