2214 lines
106 KiB
TypeScript
2214 lines
106 KiB
TypeScript
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;
|