1964 lines
92 KiB
TypeScript
1964 lines
92 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import {
|
||
Search,
|
||
Trash2,
|
||
Bell,
|
||
LogOut,
|
||
Calendar,
|
||
DollarSign,
|
||
Paperclip,
|
||
Hourglass,
|
||
Clock,
|
||
FileText,
|
||
Upload,
|
||
Send,
|
||
ChevronDown,
|
||
ShieldCheck,
|
||
MessageSquare,
|
||
X,
|
||
Plus,
|
||
ExternalLink,
|
||
CheckCircle2,
|
||
RefreshCw,
|
||
Briefcase,
|
||
TrendingUp,
|
||
Award as AwardIcon,
|
||
Info,
|
||
Sparkles,
|
||
Infinity as InfinityIcon,
|
||
Menu,
|
||
Reply,
|
||
Mail,
|
||
Layers,
|
||
FolderKanban,
|
||
PieChart,
|
||
} from 'lucide-react';
|
||
import { useAuth } from '../../auth/AuthProvider';
|
||
import { useRbac } from '../../auth/rbac';
|
||
import { useToast } from '../ui/Toast';
|
||
import { useInAppNotifications } from '../../hooks/useInAppNotifications';
|
||
import {
|
||
getApplicantData,
|
||
getMilestones,
|
||
getProjectEvidence,
|
||
getActivities,
|
||
getDisbursements,
|
||
uploadContractFile,
|
||
executeContract,
|
||
getContractSecureLink,
|
||
getEvidenceSecureLink,
|
||
submitActivityForApproval,
|
||
resolveUserIdByEmail,
|
||
getAllProgrammes,
|
||
getPortfolios,
|
||
} from '../../services/api';
|
||
import {
|
||
ApplicantData,
|
||
Project,
|
||
Milestone,
|
||
Evidence,
|
||
Activity,
|
||
Disbursement,
|
||
Contract,
|
||
Award,
|
||
Organisation,
|
||
Programme,
|
||
Portfolio,
|
||
ApprovalStatus,
|
||
ActivityStatus,
|
||
ContractStatus,
|
||
EvidenceType,
|
||
Notification,
|
||
NotificationStatus,
|
||
NotificationPlatform,
|
||
} from '../../types';
|
||
import {
|
||
formatZAR,
|
||
formatCompactZAR,
|
||
getUserEmail,
|
||
getApprovalStatusBadge,
|
||
getEvidenceTypeLabel,
|
||
} from '../../lib/formatters';
|
||
import { encodeHashIdSync } from '../../services/toolsService';
|
||
import { MessageViewer } from '../notifications/MessageViewer';
|
||
import { stripMarkdown } from '../../lib/markdownUtils';
|
||
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
|
||
import { ProjectCard, EnrichedPartnerProject } from './ProjectCard';
|
||
import { EvidenceUploadModal } from './EvidenceUploadModal';
|
||
import { PartnerMessageComposerModal } from '../notifications/PartnerMessageComposerModal';
|
||
|
||
export interface EnrichedAwardEntry {
|
||
award: Award;
|
||
contract: Contract | null;
|
||
programme: Programme | null;
|
||
portfolio: Portfolio | null;
|
||
fundingOrganisation: Organisation | null;
|
||
invitedOrganisation: Organisation | null;
|
||
linkedProject: Project | null;
|
||
}
|
||
|
||
type NavTab = 'dashboard' | 'messages' | 'contracts' | 'analytics' | 'documents';
|
||
type SortOption = 'date_added' | 'grant_amount' | 'days_left' | 'name';
|
||
|
||
const EVIDENCE_TYPE_LABELS: Record<number, string> = {
|
||
[EvidenceType.AttendanceRegister]: 'Attendance Register / Progress Report',
|
||
[EvidenceType.BankStatement]: 'Bank Statement / Financial Statement',
|
||
[EvidenceType.CompletionCertificate]: 'Completion Certificate',
|
||
[EvidenceType.FinancialInvoiceOrReceipt]: 'Invoice / Receipt',
|
||
[EvidenceType.GeotaggedPhoto]: 'Geotagged Site Photograph',
|
||
[EvidenceType.InspectionReport]: 'Inspection / Audit Report',
|
||
[EvidenceType.MeetingMinutes]: 'Meeting Minutes',
|
||
[EvidenceType.PayrollOrStipendRegister]: 'Payroll / Stipend Register',
|
||
[EvidenceType.ProcurementDocument]: 'Procurement Document',
|
||
[EvidenceType.TechnicalSpecificationOrDesign]: 'Deliverable Proof / Technical Design',
|
||
[EvidenceType.VideoRecording]: 'Video Recording',
|
||
[EvidenceType.Other]: 'Supporting Document',
|
||
};
|
||
|
||
function formatShortDate(dateStr?: string | null): string {
|
||
if (!dateStr) return 'TBD';
|
||
const d = new Date(dateStr);
|
||
if (isNaN(d.getTime())) return 'TBD';
|
||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: '2-digit' });
|
||
}
|
||
|
||
function formatFullDate(dateStr?: string | null): string {
|
||
if (!dateStr) return 'Not set';
|
||
const d = new Date(dateStr);
|
||
if (isNaN(d.getTime())) return 'Not set';
|
||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||
}
|
||
|
||
function calculateDaysLeft(endDateStr?: string | null): number {
|
||
if (!endDateStr) return 90;
|
||
const end = new Date(endDateStr).getTime();
|
||
if (isNaN(end)) return 90;
|
||
const diff = Math.ceil((end - Date.now()) / (1000 * 60 * 60 * 24));
|
||
return Math.max(0, diff);
|
||
}
|
||
|
||
function getInitials(name?: string | null): string {
|
||
if (!name) return 'PF';
|
||
const parts = name.trim().split(/\s+/);
|
||
if (parts.length >= 2) {
|
||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||
}
|
||
return name.slice(0, 2).toUpperCase();
|
||
}
|
||
|
||
export function DashboardPage() {
|
||
const { user, sub, logout } = useAuth();
|
||
const { canAdd } = useRbac();
|
||
const { success: showSuccessToast, error: showErrorToast, info: showInfoToast } = useToast();
|
||
const queryClient = useQueryClient();
|
||
|
||
const applicantUserId = sub || user?.id || '';
|
||
|
||
// Navigation & UI state
|
||
const [activeTab, setActiveTab] = useState<NavTab>('dashboard');
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [sortBy, setSortBy] = useState<SortOption>('date_added');
|
||
const [showBellDropdown, setShowBellDropdown] = useState(false);
|
||
const [showUserDropdown, setShowUserDropdown] = useState(false);
|
||
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||
|
||
// In-place expanded project state (Requirement #4: NO modal used for project details)
|
||
const [expandedProjectIds, setExpandedProjectIds] = useState<Record<string, boolean>>({});
|
||
|
||
// Target for evidence upload modal (Requirements #8, #9, #10)
|
||
const [uploadTarget, setUploadTarget] = useState<{
|
||
project: Project;
|
||
milestone?: Milestone | null;
|
||
activity: Activity;
|
||
} | null>(null);
|
||
|
||
// Messages / Contact modal state
|
||
const [contactTarget, setContactTarget] = useState<{
|
||
organisation: Organisation | null;
|
||
projectTitle?: string;
|
||
recipientEmail?: string;
|
||
recipientId?: string;
|
||
} | null>(null);
|
||
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null);
|
||
|
||
const [contractFile, setContractFile] = useState<File | null>(null);
|
||
const [signatureRef, setSignatureRef] = useState('');
|
||
const [isRefreshingAnalytics, setIsRefreshingAnalytics] = useState(false);
|
||
|
||
// Contact / Message form state
|
||
const [messageSubject, setMessageSubject] = useState('');
|
||
const [messageBody, setMessageBody] = useState('');
|
||
|
||
// In-App Notifications hook
|
||
const {
|
||
inboxNotifications,
|
||
unreadCount,
|
||
top5Unread,
|
||
markAsRead,
|
||
markAllAsRead,
|
||
deleteMessage,
|
||
sendMessage,
|
||
isSending,
|
||
} = useInAppNotifications();
|
||
|
||
// ============================================================================
|
||
// PRIMARY API CALL: GetIApplicantDataEndpoint (Requirement #2)
|
||
// ============================================================================
|
||
const {
|
||
data: applicantDataList = [],
|
||
isLoading: isLoadingApplicantData,
|
||
isFetching: isRefreshingApplicantData,
|
||
refetch: refetchApplicantData,
|
||
} = useQuery<ApplicantData[]>({
|
||
queryKey: ['partner-applicant-data', applicantUserId],
|
||
queryFn: () => getApplicantData(applicantUserId),
|
||
enabled: Boolean(applicantUserId),
|
||
});
|
||
|
||
// Extract all unique projects, contracts, awards, and organisations from ApplicantData[]
|
||
const partnerSummary = useMemo(() => {
|
||
const projectsMap = new Map<string, {
|
||
project: Project;
|
||
award: Award | null;
|
||
contract: Contract | null;
|
||
fundingOrganisation: Organisation | null;
|
||
invitedOrganisation: Organisation | null;
|
||
}>();
|
||
|
||
const contractsMap = new Map<string, {
|
||
contract: Contract;
|
||
award: Award | null;
|
||
fundingOrganisation: Organisation | null;
|
||
invitedOrganisation: Organisation | null;
|
||
}>();
|
||
|
||
const awardsList: Award[] = [];
|
||
let primaryInvitedOrg: Organisation | null = null;
|
||
let primaryFundingOrg: Organisation | null = null;
|
||
|
||
for (const record of applicantDataList) {
|
||
if (record.invitedOrganisation && !primaryInvitedOrg) {
|
||
primaryInvitedOrg = record.invitedOrganisation;
|
||
}
|
||
if (record.fundingOrganisation && !primaryFundingOrg) {
|
||
primaryFundingOrg = record.fundingOrganisation;
|
||
}
|
||
|
||
const primaryAward = record.awards && record.awards.length > 0 ? record.awards[0] : null;
|
||
if (record.awards) {
|
||
for (const aw of record.awards) {
|
||
if (!awardsList.some(x => String(x.id) === String(aw.id))) {
|
||
awardsList.push(aw);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (record.contract && record.contract.id) {
|
||
contractsMap.set(String(record.contract.id), {
|
||
contract: record.contract,
|
||
award: primaryAward,
|
||
fundingOrganisation: record.fundingOrganisation ?? null,
|
||
invitedOrganisation: record.invitedOrganisation ?? null,
|
||
});
|
||
}
|
||
|
||
if (record.projects && record.projects.length > 0) {
|
||
for (const proj of record.projects) {
|
||
if (!proj || !proj.id) continue;
|
||
const matchingAward =
|
||
record.awards?.find(a => String(a.programmeId) === String(proj.programmeId)) ||
|
||
primaryAward;
|
||
projectsMap.set(String(proj.id), {
|
||
project: proj,
|
||
award: matchingAward,
|
||
contract: record.contract ?? null,
|
||
fundingOrganisation: record.fundingOrganisation ?? null,
|
||
invitedOrganisation: record.invitedOrganisation ?? null,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
projectEntries: Array.from(projectsMap.values()),
|
||
contractEntries: Array.from(contractsMap.values()),
|
||
awardsList,
|
||
primaryInvitedOrg,
|
||
primaryFundingOrg,
|
||
};
|
||
}, [applicantDataList]);
|
||
|
||
// Fetch programmes to enrich award cards with Programme & Portfolio info (Requirement #2)
|
||
const { data: allProgrammes = [] } = useQuery<Programme[]>({
|
||
queryKey: ['all-programmes'],
|
||
queryFn: () => getAllProgrammes(1, 100),
|
||
staleTime: 5 * 60 * 1000,
|
||
});
|
||
|
||
// Fetch portfolios to enrich award cards with Portfolio info (Requirement #2)
|
||
const { data: allPortfolios = [] } = useQuery<Portfolio[]>({
|
||
queryKey: ['all-portfolios'],
|
||
queryFn: () => getPortfolios(),
|
||
staleTime: 5 * 60 * 1000,
|
||
});
|
||
|
||
const programmesMap = useMemo(() => {
|
||
const map = new Map<string, Programme>();
|
||
for (const p of allProgrammes) {
|
||
if (p && p.id !== undefined && p.id !== null) {
|
||
map.set(String(p.id), p);
|
||
}
|
||
}
|
||
return map;
|
||
}, [allProgrammes]);
|
||
|
||
const portfoliosMap = useMemo(() => {
|
||
const map = new Map<string, Portfolio>();
|
||
for (const pf of allPortfolios) {
|
||
if (pf && pf.id !== undefined && pf.id !== null) {
|
||
map.set(String(pf.id), pf);
|
||
}
|
||
}
|
||
return map;
|
||
}, [allPortfolios]);
|
||
|
||
// Construct EnrichedAwardEntry[]: Card per award, with contract, programme & portfolio info (Requirement #2)
|
||
const enrichedAwards: EnrichedAwardEntry[] = useMemo(() => {
|
||
const list: EnrichedAwardEntry[] = [];
|
||
const seenAwardIds = new Set<string>();
|
||
|
||
for (const record of applicantDataList) {
|
||
const recAwards = record.awards || [];
|
||
const recContract = record.contract || null;
|
||
const recFundingOrg = record.fundingOrganisation || partnerSummary.primaryFundingOrg;
|
||
const recInvitedOrg = record.invitedOrganisation || partnerSummary.primaryInvitedOrg;
|
||
const recProjects = record.projects || [];
|
||
|
||
for (const aw of recAwards) {
|
||
if (!aw || !aw.id) continue;
|
||
const awIdStr = String(aw.id);
|
||
if (seenAwardIds.has(awIdStr)) continue;
|
||
seenAwardIds.add(awIdStr);
|
||
|
||
// Find associated contract
|
||
let matchedContract: Contract | null = null;
|
||
if (recContract && String(recContract.awardId) === awIdStr) {
|
||
matchedContract = recContract;
|
||
} else {
|
||
for (const otherRec of applicantDataList) {
|
||
if (otherRec.contract && String(otherRec.contract.awardId) === awIdStr) {
|
||
matchedContract = otherRec.contract;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!matchedContract && recContract && recAwards.length === 1) {
|
||
matchedContract = recContract;
|
||
}
|
||
|
||
// Linked project
|
||
const linkedProject =
|
||
recProjects.find(p => String(p.id) === String(aw.projectId)) ||
|
||
recProjects.find(p => String(p.programmeId) === String(aw.programmeId)) ||
|
||
null;
|
||
|
||
// Programme lookup
|
||
const prog =
|
||
(aw.programmeId ? programmesMap.get(String(aw.programmeId)) : null) ||
|
||
(linkedProject?.programmeId ? programmesMap.get(String(linkedProject.programmeId)) : null) ||
|
||
null;
|
||
|
||
// Portfolio lookup
|
||
const port =
|
||
(prog?.portfolioId ? portfoliosMap.get(String(prog.portfolioId)) : null) ||
|
||
(aw.tenantId ? portfoliosMap.get(String(aw.tenantId)) : null) ||
|
||
(allPortfolios.length > 0 ? allPortfolios[0] : null) ||
|
||
null;
|
||
|
||
list.push({
|
||
award: aw,
|
||
contract: matchedContract,
|
||
programme: prog,
|
||
portfolio: port,
|
||
fundingOrganisation: recFundingOrg ?? null,
|
||
invitedOrganisation: recInvitedOrg ?? null,
|
||
linkedProject,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Defensive check: If there are contracts that had no award associated, also render them
|
||
for (const { contract, award, fundingOrganisation, invitedOrganisation } of partnerSummary.contractEntries) {
|
||
if (contract && contract.id) {
|
||
const alreadyCovered = list.some(item => item.contract && String(item.contract.id) === String(contract.id));
|
||
if (!alreadyCovered) {
|
||
const synthAward: Award = award || {
|
||
id: contract.awardId || `c_aw_${contract.id}`,
|
||
organisationId: contract.tenantId || 1,
|
||
programmeId: 1,
|
||
amount: contract.totalValue || 0,
|
||
status: ApprovalStatus.Approved,
|
||
createdBy: contract.createdBy || 'System',
|
||
createdAt: contract.createdAt || new Date().toISOString(),
|
||
referenceNo: `AWD-CONT-${encodeHashIdSync(contract.id)}`,
|
||
};
|
||
|
||
list.push({
|
||
award: synthAward,
|
||
contract,
|
||
programme: null,
|
||
portfolio: allPortfolios.length > 0 ? allPortfolios[0] : null,
|
||
fundingOrganisation,
|
||
invitedOrganisation,
|
||
linkedProject: null,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return list;
|
||
}, [applicantDataList, partnerSummary, programmesMap, portfoliosMap, allPortfolios]);
|
||
|
||
const projectIdsKey = useMemo(
|
||
() => partnerSummary.projectEntries.map(e => String(e.project.id)).join(','),
|
||
[partnerSummary.projectEntries]
|
||
);
|
||
|
||
// Downstream queries: Milestones, Evidence, Activities, Disbursements
|
||
const {
|
||
data: projectDetailsMap = {},
|
||
isFetching: isRefreshingProjectDetails,
|
||
refetch: refetchProjectDetails,
|
||
} = useQuery<
|
||
Record<
|
||
string,
|
||
{
|
||
milestones: Milestone[];
|
||
evidence: Evidence[];
|
||
activities: Activity[];
|
||
disbursements: Disbursement[];
|
||
}
|
||
>
|
||
>({
|
||
queryKey: ['partner-project-details', projectIdsKey],
|
||
queryFn: async () => {
|
||
const entries = partnerSummary.projectEntries;
|
||
if (entries.length === 0) return {};
|
||
|
||
let allDisbursements: Disbursement[] = [];
|
||
try {
|
||
allDisbursements = await getDisbursements(1, 100);
|
||
} catch {
|
||
allDisbursements = [];
|
||
}
|
||
|
||
const results = await Promise.all(
|
||
entries.map(async ({ project }) => {
|
||
const pid = project.id;
|
||
const [milestones, evidence, activities] = await Promise.all([
|
||
getMilestones(pid).catch(() => [] as Milestone[]),
|
||
getProjectEvidence(pid).catch(() => [] as Evidence[]),
|
||
getActivities(pid).catch(() => [] as Activity[]),
|
||
]);
|
||
|
||
const projectDisbursements = allDisbursements.filter(
|
||
d => String(d.projectId) === String(pid)
|
||
);
|
||
|
||
return [
|
||
String(pid),
|
||
{
|
||
milestones,
|
||
evidence,
|
||
activities,
|
||
disbursements: projectDisbursements,
|
||
},
|
||
] as const;
|
||
})
|
||
);
|
||
|
||
return Object.fromEntries(results);
|
||
},
|
||
enabled: partnerSummary.projectEntries.length > 0,
|
||
});
|
||
|
||
// Combine into EnrichedPartnerProject[]
|
||
const enrichedProjects: EnrichedPartnerProject[] = useMemo(() => {
|
||
return partnerSummary.projectEntries.map(entry => {
|
||
const details = projectDetailsMap[String(entry.project.id)] || {
|
||
milestones: [],
|
||
evidence: [],
|
||
activities: [],
|
||
disbursements: [],
|
||
};
|
||
return {
|
||
...entry,
|
||
...details,
|
||
};
|
||
});
|
||
}, [partnerSummary.projectEntries, projectDetailsMap]);
|
||
|
||
// Filter and sort projects
|
||
const filteredProjects = useMemo(() => {
|
||
const q = searchQuery.trim().toLowerCase();
|
||
const list = enrichedProjects.filter(item => {
|
||
if (!q) return true;
|
||
const pName = (item.project.name || '').toLowerCase();
|
||
const pDesc = (item.project.description || '').toLowerCase();
|
||
const fOrg = (item.fundingOrganisation?.name || '').toLowerCase();
|
||
return pName.includes(q) || pDesc.includes(q) || fOrg.includes(q);
|
||
});
|
||
|
||
return list.sort((a, b) => {
|
||
if (sortBy === 'name') {
|
||
return (a.project.name || '').localeCompare(b.project.name || '');
|
||
}
|
||
if (sortBy === 'grant_amount') {
|
||
const valA = a.contract?.totalValue || a.award?.amount || 0;
|
||
const valB = b.contract?.totalValue || b.award?.amount || 0;
|
||
return Number(valB) - Number(valA);
|
||
}
|
||
if (sortBy === 'days_left') {
|
||
const daysA = calculateDaysLeft(a.project.endedAt || a.contract?.expiresAt);
|
||
const daysB = calculateDaysLeft(b.project.endedAt || b.contract?.expiresAt);
|
||
return daysA - daysB;
|
||
}
|
||
// date_added default: latest created first
|
||
const timeA = new Date(a.project.createdAt || 0).getTime();
|
||
const timeB = new Date(b.project.createdAt || 0).getTime();
|
||
return timeB - timeA;
|
||
});
|
||
}, [enrichedProjects, searchQuery, sortBy]);
|
||
|
||
// Derived portfolio statistics for Analytics tab (Zero mock data)
|
||
const portfolioStats = useMemo(() => {
|
||
const totalProjects = enrichedProjects.length;
|
||
const activeProjects = enrichedProjects.filter(
|
||
p => Number(p.project.status) === ActivityStatus.Active || Number(p.project.status) === 2
|
||
).length;
|
||
const completedProjects = enrichedProjects.filter(
|
||
p => Number(p.project.status) === 6 || Number(p.project.status) === 7
|
||
).length;
|
||
|
||
const allMilestones = enrichedProjects.flatMap(p => p.milestones);
|
||
const completedMilestones = allMilestones.filter(m => Number(m.status) === ApprovalStatus.Approved);
|
||
const milestoneRate = allMilestones.length > 0
|
||
? Math.round((completedMilestones.length / allMilestones.length) * 100)
|
||
: 100;
|
||
|
||
const totalFunding = partnerSummary.contractEntries.reduce((sum, c) => sum + Number(c.contract.totalValue || 0), 0)
|
||
|| partnerSummary.awardsList.reduce((sum, a) => sum + Number(a.amount || 0), 0);
|
||
|
||
const totalDisbursed = enrichedProjects
|
||
.flatMap(p => p.disbursements)
|
||
.filter(d => Number(d.status) === ApprovalStatus.Approved)
|
||
.reduce((sum, d) => sum + Number(d.amount || 0), 0);
|
||
|
||
const disbursementRate = totalFunding > 0 ? Math.min(100, Math.round((totalDisbursed / totalFunding) * 100)) : 0;
|
||
const allEvidence = enrichedProjects.flatMap(p => p.evidence);
|
||
const totalEvidenceCount = allEvidence.length;
|
||
|
||
return {
|
||
totalProjects,
|
||
activeProjects,
|
||
completedProjects,
|
||
totalMilestones: allMilestones.length,
|
||
completedMilestones: completedMilestones.length,
|
||
milestoneRate,
|
||
totalFunding,
|
||
totalDisbursed,
|
||
disbursementRate,
|
||
totalEvidenceCount,
|
||
allEvidence,
|
||
};
|
||
}, [enrichedProjects, partnerSummary]);
|
||
|
||
// Requirement #1: Refresh Portfolio Analytics without full page reload
|
||
const handleRefreshAnalytics = async () => {
|
||
setIsRefreshingAnalytics(true);
|
||
try {
|
||
await Promise.all([
|
||
refetchApplicantData(),
|
||
refetchProjectDetails(),
|
||
queryClient.invalidateQueries({ queryKey: ['partner-applicant-data'] }),
|
||
queryClient.invalidateQueries({ queryKey: ['partner-project-details'] }),
|
||
queryClient.invalidateQueries({ queryKey: ['all-programmes'] }),
|
||
queryClient.invalidateQueries({ queryKey: ['all-portfolios'] }),
|
||
]);
|
||
showSuccessToast('Analytics Refreshed', 'Portfolio performance and deliverable metrics are up to date.');
|
||
} catch (err: any) {
|
||
showErrorToast(err || 'Failed to refresh portfolio analytics.');
|
||
} finally {
|
||
setIsRefreshingAnalytics(false);
|
||
}
|
||
};
|
||
|
||
// Toggle in-place project expansion (Requirement #4)
|
||
const handleToggleExpand = (projectId: string | number) => {
|
||
const key = String(projectId);
|
||
setExpandedProjectIds(prev => ({
|
||
...prev,
|
||
[key]: !prev[key],
|
||
}));
|
||
};
|
||
|
||
// Submit activity for approval by org staff (Requirements #10, #11)
|
||
const handleSubmitActivityForApproval = async (
|
||
activity: Activity,
|
||
milestone?: Milestone | null,
|
||
evidences?: Evidence[]
|
||
) => {
|
||
try {
|
||
const pid = activity.projectId || 0;
|
||
await submitActivityForApproval(activity, pid, evidences);
|
||
await queryClient.invalidateQueries({ queryKey: ['partner-project-details'] });
|
||
showSuccessToast(
|
||
'Activity Submitted for Review',
|
||
'Activity status updated. Organisation staff will review uploaded evidence and consider formal approval.'
|
||
);
|
||
} catch (err: any) {
|
||
showErrorToast(err);
|
||
}
|
||
};
|
||
|
||
|
||
// Email resolution helpers for messages
|
||
const getMessageSenderEmail = (notification: Notification | null): string => {
|
||
if (!notification) {
|
||
return (
|
||
partnerSummary.primaryFundingOrg?.email ||
|
||
(partnerSummary.primaryFundingOrg as any)?.Email ||
|
||
'support@ifri.khongisa.co.za'
|
||
);
|
||
}
|
||
const dest = notification.destination || '';
|
||
let rawFrom = '';
|
||
if (dest.includes('From:')) {
|
||
rawFrom = dest.split('From:')[1]?.split('|')[0]?.trim().replace(/[<>]/g, '') || '';
|
||
} else if (!dest.includes('To:') && dest.includes('@')) {
|
||
rawFrom = dest.trim();
|
||
}
|
||
|
||
if (rawFrom) {
|
||
const resolved = getUserEmail(rawFrom);
|
||
if (resolved && resolved.includes('@')) {
|
||
return resolved;
|
||
}
|
||
if (rawFrom.includes('@')) {
|
||
return rawFrom;
|
||
}
|
||
}
|
||
|
||
return (
|
||
partnerSummary.primaryFundingOrg?.email ||
|
||
(partnerSummary.primaryFundingOrg as any)?.Email ||
|
||
'support@ifri.khongisa.co.za'
|
||
);
|
||
};
|
||
|
||
const getMessageRecipientEmail = (notification: Notification | null): string => {
|
||
if (!notification) return (user?.email || (user as any)?.profile?.email || '') || (user as any)?.email || 'Partner User';
|
||
const dest = notification.destination || '';
|
||
let rawTo = '';
|
||
if (dest.includes('To:')) {
|
||
rawTo = dest.split('To:')[1]?.split('|')[0]?.trim().replace(/[<>]/g, '') || '';
|
||
}
|
||
|
||
if (rawTo) {
|
||
const resolved = getUserEmail(rawTo);
|
||
if (resolved && resolved.includes('@')) {
|
||
return resolved;
|
||
}
|
||
if (rawTo.includes('@')) {
|
||
return rawTo;
|
||
}
|
||
}
|
||
|
||
if (notification.recipient) {
|
||
const resolvedRecip = getUserEmail(notification.recipient);
|
||
if (resolvedRecip && resolvedRecip.includes('@')) {
|
||
return resolvedRecip;
|
||
}
|
||
}
|
||
|
||
return (user?.email || (user as any)?.profile?.email || '') || (user as any)?.email || 'Partner User';
|
||
};
|
||
|
||
const handleReplyToMessage = async (notification: Notification) => {
|
||
const senderEmail = getMessageSenderEmail(notification).trim().toLowerCase();
|
||
const currentUserEmail = (
|
||
(user?.email || (user as any)?.profile?.email || '') ||
|
||
(user as any)?.email ||
|
||
''
|
||
).trim().toLowerCase();
|
||
|
||
if (currentUserEmail && senderEmail && senderEmail === currentUserEmail) {
|
||
showErrorToast(
|
||
'Action Not Allowed',
|
||
'You cannot reply to a message sent from your own email address.'
|
||
);
|
||
return;
|
||
}
|
||
|
||
const replySubject = notification.subject?.startsWith('Re:')
|
||
? notification.subject
|
||
: `Re: ${notification.subject || 'Message'}`;
|
||
|
||
// Requirement #1: Resolve sender's user ID from TenantUsers by email
|
||
let senderUserId: string | undefined = undefined;
|
||
if (senderEmail) {
|
||
senderUserId = await resolveUserIdByEmail(
|
||
senderEmail,
|
||
notification.organisationId || partnerSummary.primaryFundingOrg?.id || 1
|
||
);
|
||
}
|
||
|
||
setContactTarget({
|
||
organisation: partnerSummary.primaryFundingOrg,
|
||
projectTitle: replySubject,
|
||
recipientEmail: senderEmail,
|
||
recipientId: senderUserId,
|
||
});
|
||
setMessageSubject(replySubject);
|
||
setMessageBody('');
|
||
setSelectedNotification(null);
|
||
};
|
||
|
||
// Secure download link handler
|
||
const handleOpenSecureFile = async (type: 'contract' | 'evidence', id: string | number) => {
|
||
try {
|
||
showInfoToast('Opening Document', 'Generating secure link...');
|
||
let url = '';
|
||
if (type === 'contract') {
|
||
url = await getContractSecureLink(id);
|
||
} else {
|
||
url = await getEvidenceSecureLink(id);
|
||
}
|
||
if (url && typeof window !== 'undefined') {
|
||
window.open(url, '_blank', 'noopener,noreferrer');
|
||
} else {
|
||
showErrorToast('Failed to generate a download link for this file.');
|
||
}
|
||
} catch (err: any) {
|
||
showErrorToast(err);
|
||
}
|
||
};
|
||
|
||
// Contract execution mutation
|
||
const signContractMutation = useMutation({
|
||
mutationFn: async (contract: Contract) => {
|
||
if (contractFile) {
|
||
await uploadContractFile(contract.id, contractFile);
|
||
}
|
||
await executeContract({
|
||
contractId: contract.id,
|
||
externalSignatureId: signatureRef.trim() || `SIG-${Date.now()}`,
|
||
signedDocumentUrl: contract.signedDocumentUrl || `s3://contracts/${contract.id}/signed.pdf`,
|
||
});
|
||
},
|
||
onSuccess: () => {
|
||
setContractFile(null);
|
||
setSignatureRef('');
|
||
showSuccessToast('Agreement Executed', 'Contract signed and submitted.');
|
||
queryClient.invalidateQueries({ queryKey: ['partner-applicant-data'] });
|
||
},
|
||
onError: (err) => showErrorToast(err),
|
||
});
|
||
|
||
// Contact form submission
|
||
const handleSendContactMessage = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!messageSubject.trim() || !messageBody.trim()) {
|
||
showErrorToast('Please provide both a subject and message.');
|
||
return;
|
||
}
|
||
|
||
const fundingEmail =
|
||
partnerSummary.primaryFundingOrg?.email ||
|
||
(partnerSummary.primaryFundingOrg as any)?.Email ||
|
||
'support@ifri.khongisa.co.za';
|
||
|
||
const targetEmail = contactTarget?.recipientEmail || fundingEmail;
|
||
|
||
try {
|
||
await sendMessage({
|
||
recipientEmail: targetEmail,
|
||
recipient: targetEmail,
|
||
subject: messageSubject.trim(),
|
||
message: messageBody.trim(),
|
||
organisationId: partnerSummary.primaryFundingOrg?.id || undefined,
|
||
});
|
||
showSuccessToast('Message Sent', `Your communication was delivered to ${targetEmail}.`);
|
||
setContactTarget(null);
|
||
setMessageSubject('');
|
||
setMessageBody('');
|
||
} catch (err: any) {
|
||
showErrorToast(err);
|
||
}
|
||
};
|
||
|
||
// Nav Tabs
|
||
const navTabs: { id: NavTab; label: string }[] = [
|
||
{ id: 'dashboard', label: 'Dashboard' },
|
||
{ id: 'messages', label: 'Messages' },
|
||
{ id: 'contracts', label: 'Awards & Agreements' },
|
||
{ id: 'analytics', label: 'Analytics' },
|
||
{ id: 'documents', label: 'Deliverables' },
|
||
];
|
||
|
||
return (
|
||
<div className="relative min-h-screen w-full bg-slate-950 text-slate-100 flex flex-col selection:bg-emerald-500/30 selection:text-slate-100">
|
||
{/* Ambient background glow from landing page */}
|
||
<div className="pointer-events-none fixed inset-0 z-0 bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(16,185,129,0.08),rgba(2,6,23,0))]" />
|
||
{/* =====================================================================
|
||
TOP NAVIGATION BAR — 72PX, FULL-WIDTH EDGE-TO-EDGE
|
||
===================================================================== */}
|
||
<header className="sticky top-0 z-30 w-full bg-slate-950/85 backdrop-blur-md border-b border-white/[0.08] h-[72px] flex items-center transition-all">
|
||
<div className="max-w-[1440px] mx-auto px-6 h-full flex items-center justify-between gap-4 w-full">
|
||
{/* Left: Brand Glyph & Horizontal Navigation Links */}
|
||
<div className="flex items-center gap-6 sm:gap-10 h-full">
|
||
<div
|
||
className="flex items-center gap-2.5 cursor-pointer group shrink-0"
|
||
onClick={() => setActiveTab('dashboard')}
|
||
title="Dashboard Home"
|
||
>
|
||
<PfmPartnerLogo className="h-8 w-8 drop-shadow-[0_2px_10px_rgba(16,185,129,0.25)] transition-transform group-hover:scale-105 duration-200" />
|
||
<span className="text-base sm:text-lg font-black tracking-tight bg-gradient-to-r from-emerald-400 via-teal-200 to-cyan-300 bg-clip-text text-transparent hidden sm:inline">
|
||
PFM Partner
|
||
</span>
|
||
</div>
|
||
|
||
{/* Nav Tabs */}
|
||
<nav className="hidden md:flex items-center gap-6 h-full">
|
||
{navTabs.map(tab => {
|
||
const isActive = activeTab === tab.id;
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
onClick={() => setActiveTab(tab.id)}
|
||
className={`relative h-full flex items-center text-sm transition-colors cursor-pointer ${
|
||
isActive
|
||
? 'font-bold text-slate-100'
|
||
: 'font-medium text-slate-400 hover:text-slate-100'
|
||
}`}
|
||
>
|
||
<span>{tab.label}</span>
|
||
{isActive && (
|
||
<span className="absolute bottom-0 left-0 right-0 h-[2.5px] bg-gradient-to-r from-emerald-400 to-teal-400 rounded-t-[2px] shadow-[0_0_12px_rgba(16,185,129,0.6)]" />
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
</div>
|
||
|
||
{/* Right: Search, Notifications Bell, User Menu */}
|
||
<div className="flex items-center gap-3 sm:gap-4">
|
||
{/* Mobile Navigation Toggle Button (< md) */}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowMobileMenu(!showMobileMenu);
|
||
setShowBellDropdown(false);
|
||
setShowUserDropdown(false);
|
||
}}
|
||
className="md:hidden p-2 rounded-xl bg-slate-900/80 border border-white/10 text-slate-300 hover:text-emerald-400 hover:border-emerald-500/30 transition-colors cursor-pointer"
|
||
title="Toggle Navigation Menu"
|
||
aria-label="Toggle navigation menu"
|
||
>
|
||
{showMobileMenu ? <X className="w-4 h-4 text-emerald-400" /> : <Menu className="w-4 h-4" />}
|
||
</button>
|
||
{/* Search Input */}
|
||
<div className="relative hidden sm:block w-48 md:w-64">
|
||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||
<input
|
||
type="text"
|
||
placeholder="Search projects..."
|
||
value={searchQuery}
|
||
onChange={e => setSearchQuery(e.target.value)}
|
||
className="w-full bg-slate-900/80 border border-white/10 focus:border-emerald-500/50 rounded-xl pl-9 pr-3 py-1.5 text-xs text-slate-100 placeholder-slate-400 focus:outline-none transition-colors"
|
||
/>
|
||
{searchQuery && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setSearchQuery('')}
|
||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Refresh Projects Button */}
|
||
<button
|
||
type="button"
|
||
onClick={() => refetchApplicantData()}
|
||
title="Refresh projects"
|
||
disabled={isRefreshingApplicantData}
|
||
className="p-2 rounded-xl bg-slate-900/80 border border-white/10 text-slate-400 hover:text-emerald-400 hover:border-emerald-500/30 transition-colors cursor-pointer disabled:opacity-50"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 ${isRefreshingApplicantData ? 'animate-spin text-emerald-400' : ''}`} />
|
||
</button>
|
||
|
||
{/* Notifications Bell */}
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowBellDropdown(!showBellDropdown);
|
||
setShowUserDropdown(false);
|
||
}}
|
||
className="relative p-2 rounded-xl bg-slate-900/80 border border-white/10 text-slate-400 hover:text-emerald-400 hover:border-emerald-500/30 transition-colors cursor-pointer"
|
||
title="Notifications"
|
||
>
|
||
<Bell className="w-4 h-4" />
|
||
{unreadCount > 0 && (
|
||
<span className="absolute -top-1 -right-1 flex h-4 min-w-[16px] px-1 items-center justify-center rounded-full bg-emerald-500 text-slate-950 text-[10px] font-bold ring-2 ring-slate-950">
|
||
{unreadCount > 9 ? '9+' : unreadCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
{/* Bell Dropdown */}
|
||
{showBellDropdown && (
|
||
<div className="absolute right-0 mt-2 w-80 rounded-2xl bg-slate-900/95 border border-white/10 shadow-2xl backdrop-blur-xl py-3 z-50">
|
||
<div className="px-4 pb-2 border-b border-white/[0.06] flex items-center justify-between">
|
||
<span className="text-xs font-semibold text-slate-100">Notifications</span>
|
||
{unreadCount > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => markAllAsRead()}
|
||
className="text-[11px] text-emerald-400 hover:underline cursor-pointer"
|
||
>
|
||
Mark all as read
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="max-h-72 overflow-y-auto divide-y divide-white/[0.04]">
|
||
{top5Unread.length === 0 ? (
|
||
<div className="p-4 text-center text-xs text-slate-400">No unread notifications</div>
|
||
) : (
|
||
top5Unread.map(item => (
|
||
<div
|
||
key={item.id}
|
||
onClick={() => {
|
||
markAsRead(item.id);
|
||
setSelectedNotification(item);
|
||
setShowBellDropdown(false);
|
||
}}
|
||
className="p-3 hover:bg-white/[0.03] transition-colors cursor-pointer text-left"
|
||
>
|
||
<p className="text-xs font-semibold text-slate-100 truncate">{item.subject}</p>
|
||
<p className="text-[11px] text-slate-400 line-clamp-2 mt-0.5">{stripMarkdown(item.message || '')}</p>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
<div className="pt-2 px-3 border-t border-white/[0.06] text-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setActiveTab('messages');
|
||
setShowBellDropdown(false);
|
||
}}
|
||
className="text-xs text-emerald-400 hover:underline font-medium cursor-pointer"
|
||
>
|
||
View all messages
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* User Profile Menu */}
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowUserDropdown(!showUserDropdown);
|
||
setShowBellDropdown(false);
|
||
}}
|
||
className="flex items-center gap-2 p-1.5 pl-2 rounded-xl bg-slate-900/80 border border-white/[0.06] hover:border-white/[0.12] transition-colors cursor-pointer"
|
||
>
|
||
<div className="w-6 h-6 rounded-lg bg-gradient-to-tr from-emerald-600 via-teal-600 to-cyan-600 flex items-center justify-center text-white text-[11px] font-bold">
|
||
{getInitials((user?.name || (user as any)?.profile?.name || '') || (user?.email || (user as any)?.profile?.email || '') || 'Partner')}
|
||
</div>
|
||
<span className="text-xs font-medium text-slate-100 hidden md:inline max-w-[120px] truncate">
|
||
{(user?.name || (user as any)?.profile?.name || '') || 'Partner Account'}
|
||
</span>
|
||
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
|
||
</button>
|
||
|
||
{showUserDropdown && (
|
||
<div className="absolute right-0 mt-2 w-56 rounded-2xl bg-slate-900/95 border border-white/10 shadow-2xl backdrop-blur-xl p-2 z-50 text-xs">
|
||
<div className="px-3 py-2 border-b border-white/[0.06]">
|
||
<p className="font-semibold text-slate-100 truncate">{(user?.name || (user as any)?.profile?.name || '') || 'Partner User'}</p>
|
||
<p className="text-[11px] text-slate-400 truncate">{(user?.email || (user as any)?.profile?.email || '') || ''}</p>
|
||
{partnerSummary.primaryInvitedOrg && (
|
||
<p className="text-[10px] text-emerald-400 mt-1 truncate">
|
||
{partnerSummary.primaryInvitedOrg.name}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => logout()}
|
||
className="w-full flex items-center gap-2 px-3 py-2 text-rose-400 hover:bg-rose-500/10 rounded-xl transition-colors cursor-pointer mt-1"
|
||
>
|
||
<LogOut className="w-3.5 h-3.5" />
|
||
<span>Sign Out</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* =====================================================================
|
||
MOBILE NAVIGATION DRAWER (< md) (Requirement #6)
|
||
===================================================================== */}
|
||
{showMobileMenu && (
|
||
<div className="md:hidden sticky top-[72px] z-25 w-full bg-slate-950/95 border-b border-white/10 shadow-2xl backdrop-blur-xl px-4 py-3 space-y-1.5 transition-all">
|
||
{navTabs.map(tab => {
|
||
const isActive = activeTab === tab.id;
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
onClick={() => {
|
||
setActiveTab(tab.id);
|
||
setShowMobileMenu(false);
|
||
}}
|
||
className={`flex items-center justify-between w-full px-4 py-2.5 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||
isActive
|
||
? 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 shadow-[0_0_15px_rgba(16,185,129,0.15)]'
|
||
: 'text-slate-300 hover:text-white hover:bg-white/[0.04]'
|
||
}`}
|
||
>
|
||
<span>{tab.label}</span>
|
||
{isActive && (
|
||
<span className="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(16,185,129,0.8)]" />
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* =====================================================================
|
||
MAIN DASHBOARD BODY (Max-width 1440px, full-width horizontal layout)
|
||
(Overview & Inbox sidebar deleted per Requirements #1, #2, #3)
|
||
===================================================================== */}
|
||
<main className="flex-1 w-full max-w-[1440px] mx-auto px-6 py-8">
|
||
{isLoadingApplicantData ? (
|
||
<div className="py-24 flex flex-col items-center justify-center space-y-4">
|
||
<RefreshCw className="w-8 h-8 text-emerald-400 animate-spin" />
|
||
<p className="text-sm text-slate-400">Loading your funding portfolio & projects...</p>
|
||
</div>
|
||
) : activeTab === 'dashboard' ? (
|
||
/* ===================================================================
|
||
DASHBOARD: FULL-WIDTH PROJECT LISTING (No Overview, No Inbox)
|
||
=================================================================== */
|
||
<div className="w-full space-y-6">
|
||
{/* Top Bar: Title & Sort Options */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||
<div>
|
||
<h2 className="text-[20px] leading-[28px] font-bold text-slate-100">
|
||
Ongoing Projects
|
||
</h2>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
Click “View Details” on any project to inspect its milestones, track progress, and fulfill activity evidence requirements.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Sort Selector */}
|
||
<div className="relative shrink-0">
|
||
<select
|
||
value={sortBy}
|
||
onChange={e => setSortBy(e.target.value as SortOption)}
|
||
className="appearance-none bg-slate-900/80 border border-white/[0.06] rounded-xl px-3 pr-8 py-2 text-xs font-medium text-slate-100 focus:outline-none cursor-pointer transition-colors"
|
||
>
|
||
<option value="date_added" className="bg-slate-900/80 text-slate-100">Sort by date added</option>
|
||
<option value="grant_amount" className="bg-slate-900/80 text-slate-100">Sort by award value</option>
|
||
<option value="days_left" className="bg-slate-900/80 text-slate-100">Sort by days remaining</option>
|
||
<option value="name" className="bg-slate-900/80 text-slate-100">Sort by project name</option>
|
||
</select>
|
||
<ChevronDown className="w-3.5 h-3.5 text-slate-400 absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Full-width Projects List */}
|
||
<div className="space-y-4">
|
||
{filteredProjects.length > 0 ? (
|
||
filteredProjects.map((item) => (
|
||
<ProjectCard
|
||
key={item.project.id}
|
||
item={item}
|
||
isExpanded={expandedProjectIds[String(item.project.id)] ?? false}
|
||
onToggleExpand={() => handleToggleExpand(item.project.id)}
|
||
onUploadEvidence={(target) => setUploadTarget(target)}
|
||
onSubmitActivityForApproval={handleSubmitActivityForApproval}
|
||
onOpenSecureFile={handleOpenSecureFile}
|
||
/>
|
||
))
|
||
) : (
|
||
/* Empty State */
|
||
<div className="rounded-[16px] bg-slate-900/80 border border-white/[0.06] p-8 sm:p-12 shadow-sm flex flex-col items-center justify-center text-center relative overflow-hidden">
|
||
<div className="relative mb-5">
|
||
<div className="w-14 h-14 rounded-2xl bg-emerald-500/15 border border-emerald-500/25 text-emerald-400 flex items-center justify-center shadow-lg shadow-emerald-500/10">
|
||
<Sparkles className="w-7 h-7" />
|
||
</div>
|
||
</div>
|
||
|
||
<h3 className="text-[20px] font-bold text-slate-100 tracking-tight">
|
||
{searchQuery ? 'No Matching Projects Found' : 'No Active Projects Assigned Yet'}
|
||
</h3>
|
||
|
||
<p className="text-[14px] leading-[22px] font-normal text-slate-400 max-w-lg mt-2">
|
||
{searchQuery
|
||
? `We couldn't find any ongoing projects matching "${searchQuery}". Clear your search query to see your full workspace portfolio.`
|
||
: "Welcome to your Partner Workspace! Your funding organisation hasn't assigned any active project records to your profile just yet. If your funding award was recently approved or updated, click below to sync and retrieve your latest project schedule."}
|
||
</p>
|
||
|
||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||
{searchQuery && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setSearchQuery('')}
|
||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-slate-100 text-xs font-semibold transition-all cursor-pointer"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
<span>Clear Search</span>
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => refetchApplicantData()}
|
||
disabled={isRefreshingApplicantData}
|
||
className="inline-flex items-center gap-2.5 px-6 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-xs tracking-wide shadow-lg shadow-emerald-500/25 transition-all cursor-pointer disabled:opacity-60"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 ${isRefreshingApplicantData ? 'animate-spin' : ''}`} />
|
||
<span>{isRefreshingApplicantData ? 'Refreshing Workspace...' : 'Refresh Projects'}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : activeTab === 'messages' ? (
|
||
/* ===================================================================
|
||
MESSAGES TAB
|
||
=================================================================== */
|
||
<div className="space-y-6">
|
||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||
<div>
|
||
<h1 className="text-[20px] font-bold text-slate-100">Messages & Communications</h1>
|
||
<p className="text-[12px] text-slate-400 mt-0.5">
|
||
Direct communication with your funding programme officers.
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const fundingEmail =
|
||
partnerSummary.primaryFundingOrg?.email ||
|
||
(partnerSummary.primaryFundingOrg as any)?.Email ||
|
||
'support@ifri.khongisa.co.za';
|
||
|
||
setContactTarget({
|
||
organisation: partnerSummary.primaryFundingOrg,
|
||
projectTitle: 'General Support Enquiry',
|
||
recipientEmail: fundingEmail,
|
||
});
|
||
setMessageSubject('');
|
||
setMessageBody('');
|
||
}}
|
||
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-xs shadow-lg shadow-emerald-500/20 transition-all cursor-pointer"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
<span>New Message</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="rounded-[16px] bg-slate-900/80 border border-white/[0.06] divide-y divide-white/[0.06] overflow-hidden">
|
||
{inboxNotifications.length === 0 ? (
|
||
<div className="p-12 text-center text-slate-400 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-emerald-500/15 border border-[#8B5CF6]/30 text-emerald-400 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-slate-100 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-slate-400 mt-1 line-clamp-2">{stripMarkdown(msg.message || '')}</p>
|
||
<div className="flex items-center gap-2 mt-1.5 text-[11px] text-slate-500 font-mono">
|
||
<span>From: <strong className="text-slate-300 font-medium">{getMessageSenderEmail(msg)}</strong></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2.5 shrink-0">
|
||
<span className="text-xs text-slate-400/70">
|
||
{formatFullDate(msg.createdAt)}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={e => {
|
||
e.stopPropagation();
|
||
deleteMessage(msg.id);
|
||
if (selectedNotification?.id === msg.id) {
|
||
setSelectedNotification(null);
|
||
}
|
||
}}
|
||
className="p-1.5 rounded-lg text-slate-400 hover:text-rose-400 hover:bg-rose-500/10 border border-transparent hover:border-rose-500/20 transition cursor-pointer"
|
||
title="Delete message (Archive)"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : activeTab === 'contracts' ? (
|
||
/* ===================================================================
|
||
CONTRACTS & APPROVED AWARDS TAB (Card per Award with Contract, Programme & Portfolio)
|
||
(Requirement #2)
|
||
=================================================================== */
|
||
<div className="space-y-6">
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-white/[0.08] pb-4">
|
||
<div>
|
||
<div className="flex items-center gap-2.5">
|
||
<h1 className="text-[20px] font-bold text-slate-100">Contracts & Approved Awards</h1>
|
||
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-mono font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
|
||
{enrichedAwards.length} {enrichedAwards.length === 1 ? 'Approved Award' : 'Approved Awards'}
|
||
</span>
|
||
</div>
|
||
<p className="text-[12px] text-slate-400 mt-1">
|
||
Full post-award governance oversight: Review your sanctioned grant awards, binding contracts, funding programmes, and governing portfolios.
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleRefreshAnalytics}
|
||
disabled={isRefreshingAnalytics || isRefreshingApplicantData || isRefreshingProjectDetails}
|
||
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-xl bg-white/[0.04] hover:bg-white/[0.08] border border-white/10 text-slate-300 hover:text-white text-xs font-semibold transition-all cursor-pointer shadow-sm disabled:opacity-50 shrink-0 self-start sm:self-auto"
|
||
title="Refresh awards, agreements and programmes"
|
||
>
|
||
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshingAnalytics || isRefreshingApplicantData || isRefreshingProjectDetails ? 'animate-spin' : ''}`} />
|
||
<span>{isRefreshingAnalytics ? 'Refreshing...' : 'Refresh Awards'}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||
{enrichedAwards.length === 0 ? (
|
||
<div className="col-span-full rounded-2xl bg-slate-900/80 border border-white/[0.06] p-12 text-center space-y-3">
|
||
<div className="w-12 h-12 rounded-2xl bg-emerald-500/10 text-emerald-400 flex items-center justify-center mx-auto border border-emerald-500/20">
|
||
<AwardIcon className="w-6 h-6" />
|
||
</div>
|
||
<h3 className="text-base font-bold text-slate-100">No Approved Awards Issued Yet</h3>
|
||
<p className="text-xs text-slate-400 max-w-md mx-auto leading-relaxed">
|
||
Once your grant application is sanctioned by the approval committee, your official award record, governing programme, and legal contract agreement will appear here automatically.
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={handleRefreshAnalytics}
|
||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold transition-colors cursor-pointer mt-2"
|
||
>
|
||
<RefreshCw className="w-3.5 h-3.5" />
|
||
<span>Check for Awards</span>
|
||
</button>
|
||
</div>
|
||
) : (
|
||
enrichedAwards.map(({ award, contract, programme, portfolio, fundingOrganisation, invitedOrganisation, linkedProject }, idx) => {
|
||
const isExecuted = contract ? Number(contract.status) === ContractStatus.Executed : false;
|
||
const contractVer = contract ? ((contract as any).version || 1) : null;
|
||
const awardHashId = encodeHashIdSync(award.id);
|
||
const awardRef = award.referenceNo || `AWD-${awardHashId || (idx + 1)}`;
|
||
const statusInfo = getApprovalStatusBadge(award.status);
|
||
|
||
return (
|
||
<div
|
||
key={`award-${award.id}-${idx}`}
|
||
className="rounded-2xl bg-slate-900/85 border border-white/[0.08] hover:border-emerald-500/30 transition-all p-6 sm:p-7 flex flex-col justify-between gap-6 shadow-xl shadow-black/20 backdrop-blur-xl group relative overflow-hidden"
|
||
>
|
||
{/* Top ambient glow */}
|
||
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-48 h-48 bg-emerald-500/5 rounded-full blur-2xl pointer-events-none group-hover:bg-emerald-500/10 transition-colors" />
|
||
|
||
{/* SECTION 1: AWARD HEADER & METRICS */}
|
||
<div className="space-y-5 relative z-10">
|
||
{/* Row 1: Badges & Status */}
|
||
<div className="flex flex-wrap items-center justify-between gap-2.5">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<div className="p-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400">
|
||
<AwardIcon className="w-4 h-4" />
|
||
</div>
|
||
<span className="font-mono text-xs font-bold text-emerald-300 bg-emerald-950/80 border border-emerald-500/30 px-2.5 py-0.5 rounded-md">
|
||
#{awardRef}
|
||
</span>
|
||
<span className="text-[11px] font-semibold text-emerald-400 uppercase tracking-wider bg-emerald-500/10 px-2 py-0.5 rounded-md border border-emerald-500/20">
|
||
Approved Award
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs ${statusInfo.className}`}>
|
||
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot}`} />
|
||
<span>{statusInfo.label}</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 2: Organisation Titles */}
|
||
<div>
|
||
<h3 className="text-lg sm:text-xl font-bold text-white tracking-tight">
|
||
{fundingOrganisation?.name || 'Grant Funding Authority'}
|
||
</h3>
|
||
<p className="text-xs text-slate-400 mt-1">
|
||
Recipient Organisation:{' '}
|
||
<span className="text-slate-200 font-medium">
|
||
{invitedOrganisation?.name || partnerSummary.primaryInvitedOrg?.name || 'Partner Recipient'}
|
||
</span>
|
||
{award.createdAt && ` • Sanctioned: ${formatFullDate(award.createdAt)}`}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Row 3: Financial Figures Card */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 p-4 rounded-xl bg-slate-950/60 border border-white/5">
|
||
<div>
|
||
<p className="text-[11px] font-medium text-slate-400 uppercase tracking-wider">Approved Award Capital</p>
|
||
<p className="text-xl font-bold text-emerald-400 mt-0.5">
|
||
{formatZAR(award.amount)}
|
||
</p>
|
||
<p className="text-[11px] text-slate-500 mt-0.5">Official Sanctioned Grant Amount</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-[11px] font-medium text-slate-400 uppercase tracking-wider">Contracted Agreement Value</p>
|
||
<p className="text-xl font-bold text-teal-300 mt-0.5">
|
||
{formatZAR(contract?.totalValue || award.amount)}
|
||
</p>
|
||
<p className="text-[11px] text-slate-500 mt-0.5">
|
||
{contract ? `Binding Agreement Value (v${contractVer})` : 'Pending Contract Execution'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* SECTION 2: PROGRAMME & PORTFOLIO GOVERNANCE HIERARCHY (Requirement #2) */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
|
||
{/* Programme Box */}
|
||
<div className="p-3.5 rounded-xl bg-white/[0.02] border border-white/[0.06] space-y-1.5">
|
||
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold">
|
||
<Layers className="w-3.5 h-3.5" />
|
||
<span>Funding Programme</span>
|
||
</div>
|
||
<p className="text-sm font-semibold text-slate-200 line-clamp-1">
|
||
{programme?.name || award.programmeName || 'Strategic Development Programme'}
|
||
</p>
|
||
<p className="text-[11px] text-slate-400 font-mono">
|
||
ID: #{encodeHashIdSync(programme?.id || award.programmeId) || (award.programmeId ?? 'PROG')}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Portfolio Box */}
|
||
<div className="p-3.5 rounded-xl bg-white/[0.02] border border-white/[0.06] space-y-1.5">
|
||
<div className="flex items-center gap-2 text-teal-400 text-xs font-semibold">
|
||
<Briefcase className="w-3.5 h-3.5" />
|
||
<span>Governing Portfolio</span>
|
||
</div>
|
||
<p className="text-sm font-semibold text-slate-200 line-clamp-1">
|
||
{portfolio?.name || 'National Impact Portfolio'}
|
||
</p>
|
||
<p className="text-[11px] text-slate-400 font-mono line-clamp-1">
|
||
{portfolio?.description || 'Strategic development and grant allocation portfolio.'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Linked Project Banner (if linked) */}
|
||
{linkedProject && (
|
||
<div className="flex items-center justify-between p-3 rounded-xl bg-emerald-500/[0.04] border border-emerald-500/20 text-xs">
|
||
<div className="flex items-center gap-2 min-w-0 pr-2">
|
||
<FolderKanban className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||
<div className="min-w-0">
|
||
<span className="text-slate-400">Linked Project: </span>
|
||
<span className="font-semibold text-slate-200 truncate">{linkedProject.name}</span>
|
||
</div>
|
||
</div>
|
||
<span className="text-[11px] font-mono text-emerald-300 bg-emerald-950/60 border border-emerald-500/30 px-2 py-0.5 rounded">
|
||
#{encodeHashIdSync(linkedProject.id)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* SECTION 3: CONTRACT & AGREEMENT DETAILS (Requirement #2) */}
|
||
<div className="p-4 rounded-xl bg-slate-950/80 border border-white/[0.08] space-y-3">
|
||
{contract ? (
|
||
<>
|
||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/[0.06] pb-2.5">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-bold text-white tracking-wide">
|
||
Grant Agreement (v{contractVer})
|
||
</span>
|
||
<span className="text-[10px] font-mono text-slate-400">
|
||
ID: #{encodeHashIdSync(contract.id)}
|
||
</span>
|
||
</div>
|
||
<span
|
||
className={`px-2.5 py-0.5 rounded-full text-[11px] 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>
|
||
|
||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||
<div>
|
||
<p className="text-[11px] text-slate-400">Contract Validity Period</p>
|
||
<p className="text-slate-200 font-medium mt-0.5">
|
||
{formatShortDate(contract.effectiveAt)} – {formatFullDate(contract.expiresAt)}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-[11px] text-slate-400">Digital Execution</p>
|
||
<p className="text-slate-200 font-medium mt-0.5">
|
||
{contract.externalSignatureId ? `Ref: ${contract.externalSignatureId}` : (isExecuted ? 'Countersigned' : 'Awaiting Signature')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Contract Action Buttons */}
|
||
<div className="flex flex-wrap items-center gap-2.5 pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleOpenSecureFile('contract', contract.id)}
|
||
className="flex-1 py-2 px-3.5 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-slate-200 hover:text-white text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer border border-white/5"
|
||
>
|
||
<ExternalLink className="w-3.5 h-3.5 text-emerald-400" />
|
||
<span>Download Agreement PDF</span>
|
||
</button>
|
||
{!isExecuted && canAdd('grant_financials') && (
|
||
<button
|
||
type="button"
|
||
onClick={() => signContractMutation.mutate(contract)}
|
||
disabled={signContractMutation.isPending}
|
||
className="flex-1 py-2 px-3.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer disabled:opacity-50 shadow-sm shadow-emerald-600/30"
|
||
>
|
||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||
<span>{signContractMutation.isPending ? 'Signing...' : 'Accept & Sign Agreement'}</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="flex items-start gap-3 py-1">
|
||
<Hourglass className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||
<div className="space-y-0.5">
|
||
<p className="text-xs font-semibold text-amber-300">Contract In Preparation (BR-002)</p>
|
||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||
The formal legal agreement for this approved award is being generated by the Funding PMO. Once generated, it will appear here for review and digital signature.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : activeTab === 'analytics' ? (
|
||
/* ===================================================================
|
||
ANALYTICS TAB (Real API Derived Portfolio Metrics with Refresh Button)
|
||
(Requirement #1)
|
||
=================================================================== */
|
||
<div className="space-y-6">
|
||
{/* Header with Refresh Button */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-white/[0.08] pb-4">
|
||
<div>
|
||
<div className="flex items-center gap-2.5">
|
||
<h1 className="text-[20px] font-bold text-slate-100">Portfolio Analytics</h1>
|
||
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-mono font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
|
||
Live Performance
|
||
</span>
|
||
</div>
|
||
<p className="text-[12px] text-slate-400 mt-1">
|
||
Real-time performance metrics, milestone completion rates, and capital utilization across your assigned projects.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Requirement #1: Refresh Button */}
|
||
<button
|
||
type="button"
|
||
onClick={handleRefreshAnalytics}
|
||
disabled={isRefreshingAnalytics || isRefreshingApplicantData || isRefreshingProjectDetails}
|
||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-500/15 hover:bg-emerald-500/25 border border-emerald-500/30 text-emerald-300 hover:text-emerald-200 text-xs font-semibold transition-all cursor-pointer shadow-sm hover:shadow-[0_0_15px_rgba(16,185,129,0.25)] disabled:opacity-50 shrink-0 self-start sm:self-auto"
|
||
title="Refresh portfolio performance and deliverable metrics"
|
||
>
|
||
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshingAnalytics || isRefreshingApplicantData || isRefreshingProjectDetails ? 'animate-spin' : ''}`} />
|
||
<span>{isRefreshingAnalytics ? 'Refreshing Data...' : 'Refresh Analytics'}</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Top KPI Metric Cards (4 Cards Grid) */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||
{/* Card 1: Milestone Fulfillment Rate */}
|
||
<div className="p-5 rounded-2xl bg-slate-900/80 border border-white/[0.06] relative overflow-hidden flex flex-col justify-between">
|
||
<div>
|
||
<div className="flex items-center justify-between text-emerald-400 mb-2">
|
||
<TrendingUp className="w-5 h-5" />
|
||
<span className="text-xs font-semibold text-emerald-400 font-mono">
|
||
{portfolioStats.completedMilestones} / {portfolioStats.totalMilestones} Verified
|
||
</span>
|
||
</div>
|
||
<p className="text-3xl font-extrabold text-white tracking-tight">{portfolioStats.milestoneRate}%</p>
|
||
<p className="text-xs text-slate-400 mt-1 font-medium">Milestone Delivery Rate</p>
|
||
</div>
|
||
<div className="w-full bg-slate-950/80 h-1.5 rounded-full overflow-hidden mt-4">
|
||
<div
|
||
className="bg-emerald-400 h-full rounded-full transition-all duration-500"
|
||
style={{ width: `${Math.min(100, Math.max(0, portfolioStats.milestoneRate))}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Card 2: Active Projects */}
|
||
<div className="p-5 rounded-2xl bg-slate-900/80 border border-white/[0.06] relative overflow-hidden flex flex-col justify-between">
|
||
<div>
|
||
<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-teal-300 font-mono">
|
||
{portfolioStats.activeProjects} Active
|
||
</span>
|
||
</div>
|
||
<p className="text-3xl font-extrabold text-white tracking-tight">{portfolioStats.totalProjects}</p>
|
||
<p className="text-xs text-slate-400 mt-1 font-medium">Funded Projects in Execution</p>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-4">
|
||
{portfolioStats.completedProjects} Completed • {Math.max(0, portfolioStats.totalProjects - portfolioStats.activeProjects - portfolioStats.completedProjects)} In Preparation
|
||
</p>
|
||
</div>
|
||
|
||
{/* Card 3: Capital Allocation & Drawdowns */}
|
||
<div className="p-5 rounded-2xl bg-slate-900/80 border border-white/[0.06] relative overflow-hidden flex flex-col justify-between">
|
||
<div>
|
||
<div className="flex items-center justify-between text-cyan-400 mb-2">
|
||
<DollarSign className="w-5 h-5" />
|
||
<span className="text-xs font-semibold text-cyan-300 font-mono">
|
||
{portfolioStats.disbursementRate}% Disbursed
|
||
</span>
|
||
</div>
|
||
<p className="text-2xl font-extrabold text-white tracking-tight">
|
||
{formatCompactZAR(portfolioStats.totalDisbursed)}
|
||
</p>
|
||
<p className="text-xs text-slate-400 mt-1 font-medium">
|
||
of {formatCompactZAR(portfolioStats.totalFunding)} Total Capital
|
||
</p>
|
||
</div>
|
||
<div className="w-full bg-slate-950/80 h-1.5 rounded-full overflow-hidden mt-4">
|
||
<div
|
||
className="bg-cyan-400 h-full rounded-full transition-all duration-500"
|
||
style={{ width: `${Math.min(100, Math.max(0, portfolioStats.disbursementRate))}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Card 4: Audited Deliverables & Evidence */}
|
||
<div className="p-5 rounded-2xl bg-slate-900/80 border border-white/[0.06] relative overflow-hidden flex flex-col justify-between">
|
||
<div>
|
||
<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-300 font-mono">Audited</span>
|
||
</div>
|
||
<p className="text-3xl font-extrabold text-white tracking-tight">{portfolioStats.totalEvidenceCount}</p>
|
||
<p className="text-xs text-slate-400 mt-1 font-medium">Uploaded Evidence Deliverables</p>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-4">
|
||
Substantiating milestone and indicator outcomes
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Progress Bars Section */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||
{/* Delivery Progress Bar Card */}
|
||
<div className="p-6 rounded-2xl bg-slate-900/80 border border-white/[0.06] space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<TrendingUp className="w-4 h-4 text-emerald-400" />
|
||
<h3 className="text-sm font-bold text-white">Milestone Delivery Velocity</h3>
|
||
</div>
|
||
<span className="font-mono text-sm font-bold text-emerald-400">
|
||
{portfolioStats.completedMilestones} / {portfolioStats.totalMilestones} ({portfolioStats.milestoneRate}%)
|
||
</span>
|
||
</div>
|
||
<div className="w-full bg-slate-950 h-3 rounded-full overflow-hidden p-0.5 border border-white/5">
|
||
<div
|
||
className="bg-gradient-to-r from-teal-500 to-emerald-400 h-full rounded-full transition-all duration-700 shadow-sm shadow-emerald-500/50"
|
||
style={{ width: `${Math.min(100, Math.max(0, portfolioStats.milestoneRate))}%` }}
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-slate-400 leading-relaxed">
|
||
Percentage of contractual project milestones formally approved and verified through uploaded deliverable proofs.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Capital Drawdown Bar Card */}
|
||
<div className="p-6 rounded-2xl bg-slate-900/80 border border-white/[0.06] space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<DollarSign className="w-4 h-4 text-cyan-400" />
|
||
<h3 className="text-sm font-bold text-white">Disbursement vs Total Allocation</h3>
|
||
</div>
|
||
<span className="font-mono text-sm font-bold text-cyan-400">
|
||
{formatZAR(portfolioStats.totalDisbursed)} / {formatZAR(portfolioStats.totalFunding)}
|
||
</span>
|
||
</div>
|
||
<div className="w-full bg-slate-950 h-3 rounded-full overflow-hidden p-0.5 border border-white/5">
|
||
<div
|
||
className="bg-gradient-to-r from-cyan-500 to-emerald-400 h-full rounded-full transition-all duration-700 shadow-sm shadow-cyan-500/50"
|
||
style={{ width: `${Math.min(100, Math.max(0, portfolioStats.disbursementRate))}%` }}
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-slate-400 leading-relaxed">
|
||
Tranche payments released following conditions precedent fulfillment and milestone verification ({portfolioStats.disbursementRate}% capital utilization).
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Project Performance Breakdown Table */}
|
||
<div className="rounded-2xl bg-slate-900/80 border border-white/[0.06] overflow-hidden">
|
||
<div className="p-5 border-b border-white/[0.06] flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<PieChart className="w-4 h-4 text-emerald-400" />
|
||
<h3 className="text-sm font-bold text-white">Project-Level Performance Summary</h3>
|
||
</div>
|
||
<span className="text-xs text-slate-400 font-mono">
|
||
{enrichedProjects.length} Projects Tracked
|
||
</span>
|
||
</div>
|
||
|
||
{enrichedProjects.length === 0 ? (
|
||
<div className="p-8 text-center text-xs text-slate-400">
|
||
No active projects assigned to display performance analytics.
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-white/[0.06]">
|
||
{enrichedProjects.map(item => {
|
||
const projectMilestones = item.milestones || [];
|
||
const approvedM = projectMilestones.filter(m => Number(m.status) === ApprovalStatus.Approved).length;
|
||
const rate = projectMilestones.length > 0 ? Math.round((approvedM / projectMilestones.length) * 100) : 100;
|
||
const projectDisbursed = (item.disbursements || [])
|
||
.filter(d => Number(d.status) === ApprovalStatus.Approved)
|
||
.reduce((sum, d) => sum + Number(d.amount || 0), 0);
|
||
|
||
return (
|
||
<div key={item.project.id} className="p-4 sm:p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-white/[0.02] transition-colors">
|
||
<div className="min-w-0 space-y-1">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="font-mono text-xs font-bold text-emerald-300 bg-emerald-950/80 border border-emerald-500/30 px-2 py-0.5 rounded">
|
||
#{encodeHashIdSync(item.project.id)}
|
||
</span>
|
||
<h4 className="text-sm font-bold text-slate-100 truncate">{item.project.name}</h4>
|
||
</div>
|
||
<p className="text-xs text-slate-400">
|
||
Funder: <span className="text-slate-300 font-medium">{item.fundingOrganisation?.name || 'Programme Office'}</span>
|
||
{' • '}
|
||
Grant: <strong className="text-emerald-400">{formatZAR(item.contract?.totalValue || item.award?.amount || 0)}</strong>
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-6 shrink-0 text-xs">
|
||
<div>
|
||
<span className="text-[10px] text-slate-500 uppercase font-bold block">Milestone Progress</span>
|
||
<div className="flex items-center gap-2 mt-0.5">
|
||
<div className="w-20 bg-slate-950 h-2 rounded-full overflow-hidden">
|
||
<div className="bg-emerald-400 h-full rounded-full" style={{ width: `${rate}%` }} />
|
||
</div>
|
||
<span className="font-mono font-semibold text-emerald-300">{rate}%</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-right">
|
||
<span className="text-[10px] text-slate-500 uppercase font-bold block">Disbursed</span>
|
||
<span className="font-mono font-bold text-cyan-300 mt-0.5 block">
|
||
{formatCompactZAR(projectDisbursed)}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="text-right">
|
||
<span className="text-[10px] text-slate-500 uppercase font-bold block">Evidence</span>
|
||
<span className="font-mono font-bold text-amber-300 mt-0.5 block">
|
||
{item.evidence.length} Files
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* ===================================================================
|
||
DOCUMENTS & DELIVERABLES TAB
|
||
=================================================================== */
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h1 className="text-[20px] font-bold text-slate-100">Deliverables & Evidence Documents</h1>
|
||
<p className="text-[12px] text-slate-400 mt-0.5">
|
||
Invoices, reports, and verified deliverable proofs submitted across projects.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="rounded-[16px] bg-slate-900/80 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-slate-400">
|
||
No documents uploaded yet. Expand any project on your Dashboard to upload proof files.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-white/[0.06]">
|
||
{enrichedProjects
|
||
.flatMap(p => p.evidence.map(ev => ({ ev, project: p.project })))
|
||
.map(({ ev, project }) => {
|
||
const evType = Number((ev as any).evidenceType ?? ev.type);
|
||
const evDate = (ev as any).submittedAt ?? ev.createdAt;
|
||
return (
|
||
<div
|
||
key={ev.id}
|
||
className="p-4 sm:p-5 flex items-center justify-between gap-4 hover:bg-white/[0.02]"
|
||
>
|
||
<div className="flex items-center gap-3.5 min-w-0">
|
||
<div className="p-2.5 rounded-xl bg-[#8B5CF6]/10 text-emerald-400">
|
||
<FileText className="w-5 h-5" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-semibold text-slate-100 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-white/[0.06] hover:bg-white/[0.1] text-xs font-semibold text-emerald-400 flex items-center gap-1.5 shrink-0 cursor-pointer"
|
||
>
|
||
<ExternalLink className="w-3.5 h-3.5" />
|
||
<span>View File</span>
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</main>
|
||
|
||
{/* =====================================================================
|
||
EVIDENCE UPLOAD MODAL (Supports Multiple Uploads per Activity)
|
||
(Requirements #8, #9, #10)
|
||
===================================================================== */}
|
||
{uploadTarget && (
|
||
<EvidenceUploadModal
|
||
isOpen={true}
|
||
onClose={() => setUploadTarget(null)}
|
||
project={uploadTarget.project}
|
||
milestone={uploadTarget.milestone}
|
||
activity={uploadTarget.activity}
|
||
onEvidenceUploaded={() => {
|
||
queryClient.invalidateQueries({ queryKey: ['partner-project-details'] });
|
||
showSuccessToast(
|
||
'Evidence Uploaded',
|
||
'Supporting document uploaded and linked to activity.'
|
||
);
|
||
setUploadTarget(null);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* =====================================================================
|
||
SEND MESSAGE MODAL (WYSIWYG Composer, strictly In-App, centered to window)
|
||
(Requirements #1, #2, #8)
|
||
===================================================================== */}
|
||
{contactTarget && (
|
||
<PartnerMessageComposerModal
|
||
isOpen={true}
|
||
onClose={() => setContactTarget(null)}
|
||
recipientEmail={
|
||
contactTarget.recipientEmail ||
|
||
partnerSummary.primaryFundingOrg?.email ||
|
||
'support@ifri.khongisa.co.za'
|
||
}
|
||
organisationName={
|
||
contactTarget.organisation?.name ||
|
||
partnerSummary.primaryFundingOrg?.name ||
|
||
'Programme Management Office'
|
||
}
|
||
initialSubject={
|
||
messageSubject ||
|
||
(contactTarget.projectTitle?.startsWith('Re:') ? contactTarget.projectTitle : '')
|
||
}
|
||
isSending={isSending}
|
||
onSend={async (subj, markdown) => {
|
||
const fundingEmail =
|
||
partnerSummary.primaryFundingOrg?.email ||
|
||
(partnerSummary.primaryFundingOrg as any)?.Email ||
|
||
'support@ifri.khongisa.co.za';
|
||
|
||
const targetEmail = contactTarget.recipientEmail || fundingEmail;
|
||
|
||
await sendMessage({
|
||
recipientEmail: targetEmail,
|
||
recipient: targetEmail,
|
||
recipientId: contactTarget.recipientId,
|
||
subject: subj,
|
||
message: markdown,
|
||
platform: NotificationPlatform.InApp,
|
||
organisationId: contactTarget.organisation?.id || partnerSummary.primaryFundingOrg?.id || 1,
|
||
});
|
||
|
||
showSuccessToast('Message Sent', `Your communication was delivered to ${targetEmail}.`);
|
||
setContactTarget(null);
|
||
setMessageSubject('');
|
||
setMessageBody('');
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* =====================================================================
|
||
MESSAGE READER MODAL (Centered to Window via Portal)
|
||
(Requirements #1, #2, #3, #4, #8)
|
||
===================================================================== */}
|
||
{selectedNotification &&
|
||
typeof document !== 'undefined' &&
|
||
createPortal(
|
||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-950/85 backdrop-blur-md p-4 sm:p-6 overflow-y-auto animate-in fade-in duration-200">
|
||
<div className="w-full max-w-3xl sm:max-w-4xl rounded-3xl bg-slate-900 border border-white/10 shadow-2xl p-6 sm:p-8 space-y-5 my-auto text-slate-100">
|
||
{/* Header */}
|
||
<div className="flex items-start justify-between border-b border-white/[0.08] pb-4 gap-4">
|
||
<div className="space-y-1.5 min-w-0">
|
||
<h3 className="text-lg sm:text-xl font-bold text-slate-100 truncate">
|
||
{selectedNotification.subject || 'Message Details'}
|
||
</h3>
|
||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-400">
|
||
<div>
|
||
From:{' '}
|
||
<span className="font-mono text-emerald-400 font-semibold">
|
||
{getMessageSenderEmail(selectedNotification)}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
To:{' '}
|
||
<span className="font-mono text-slate-300">
|
||
{getMessageRecipientEmail(selectedNotification)}
|
||
</span>
|
||
</div>
|
||
<div className="text-slate-500">
|
||
{formatFullDate(selectedNotification.createdAt)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedNotification(null)}
|
||
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors cursor-pointer shrink-0"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Message Content Viewer */}
|
||
<div className="p-5 sm:p-6 rounded-2xl bg-slate-950/80 border border-white/5 text-slate-200 leading-relaxed min-h-[180px] max-h-[60vh] overflow-y-auto">
|
||
<MessageViewer
|
||
content={selectedNotification.message || ''}
|
||
message={selectedNotification.message || ''}
|
||
subject={selectedNotification.subject || undefined}
|
||
createdDate={selectedNotification.createdAt}
|
||
platform={selectedNotification.platform}
|
||
variant="card"
|
||
/>
|
||
</div>
|
||
|
||
{/* Action Buttons: Reply, Delete, and Close */}
|
||
<div className="flex items-center justify-between pt-2 border-t border-white/[0.08]">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleReplyToMessage(selectedNotification)}
|
||
className="px-5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all cursor-pointer"
|
||
>
|
||
<Reply className="w-3.5 h-3.5" />
|
||
<span>Reply</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
deleteMessage(selectedNotification.id);
|
||
setSelectedNotification(null);
|
||
}}
|
||
className="px-4 py-2.5 rounded-xl bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/30 text-xs font-semibold flex items-center gap-2 transition-all cursor-pointer"
|
||
title="Delete message (Archive)"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
<span>Delete</span>
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedNotification(null)}
|
||
className="px-5 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold transition-colors cursor-pointer"
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</div>
|
||
);
|
||
} |