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