1210 lines
53 KiB
TypeScript
1210 lines
53 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import {
|
||
Search,
|
||
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,
|
||
} 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,
|
||
} from '../../services/api';
|
||
import {
|
||
ApplicantData,
|
||
Project,
|
||
Milestone,
|
||
Evidence,
|
||
Activity,
|
||
Disbursement,
|
||
Contract,
|
||
Award,
|
||
Organisation,
|
||
ApprovalStatus,
|
||
ActivityStatus,
|
||
ContractStatus,
|
||
EvidenceType,
|
||
Notification,
|
||
NotificationStatus,
|
||
} from '../../types';
|
||
import { formatZAR, formatCompactZAR } from '../../lib/formatters';
|
||
import { MessageViewer } from '../notifications/MessageViewer';
|
||
import { stripMarkdown } from '../../lib/markdownUtils';
|
||
import { ProjectCard, EnrichedPartnerProject } from './ProjectCard';
|
||
import { EvidenceUploadModal } from './EvidenceUploadModal';
|
||
|
||
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);
|
||
|
||
// 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('');
|
||
|
||
// Contact / Message form state
|
||
const [messageSubject, setMessageSubject] = useState('');
|
||
const [messageBody, setMessageBody] = useState('');
|
||
|
||
// In-App Notifications hook
|
||
const {
|
||
inboxNotifications,
|
||
unreadCount,
|
||
top5Unread,
|
||
markAsRead,
|
||
markAllAsRead,
|
||
sendMessage,
|
||
isSending,
|
||
} = useInAppNotifications();
|
||
|
||
// ============================================================================
|
||
// PRIMARY API CALL: GetIApplicantDataEndpoint (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]);
|
||
|
||
const projectIdsKey = useMemo(
|
||
() => partnerSummary.projectEntries.map(e => String(e.project.id)).join(','),
|
||
[partnerSummary.projectEntries]
|
||
);
|
||
|
||
// Downstream queries: Milestones, Evidence, Activities, Disbursements
|
||
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,
|
||
});
|
||
|
||
// 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 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 totalEvidenceCount = enrichedProjects.flatMap(p => p.evidence).length;
|
||
|
||
return {
|
||
totalProjects,
|
||
totalMilestones: allMilestones.length,
|
||
completedMilestones: completedMilestones.length,
|
||
milestoneRate,
|
||
totalFunding,
|
||
totalDisbursed,
|
||
totalEvidenceCount,
|
||
};
|
||
}, [enrichedProjects, partnerSummary]);
|
||
|
||
// 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);
|
||
}
|
||
};
|
||
|
||
// 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 recipient =
|
||
contactTarget?.recipientId ||
|
||
contactTarget?.recipientEmail ||
|
||
contactTarget?.organisation?.name ||
|
||
applicantUserId;
|
||
|
||
try {
|
||
await sendMessage({
|
||
recipient,
|
||
subject: messageSubject.trim(),
|
||
message: messageBody.trim(),
|
||
});
|
||
showSuccessToast('Message Sent', 'Your communication was delivered to the programme team.');
|
||
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="min-h-screen w-full bg-[#0F0E17] text-[#F1F1F6] flex flex-col selection:bg-[#8B5CF6]/30 selection:text-[#F1F1F6]">
|
||
{/* =====================================================================
|
||
TOP NAVIGATION BAR — 72PX, FULL-WIDTH EDGE-TO-EDGE
|
||
===================================================================== */}
|
||
<header className="sticky top-0 z-30 w-full bg-[#0F0E17]/95 backdrop-blur-md border-b border-white/[0.06] h-[72px] flex items-center transition-all">
|
||
<div className="max-w-[1440px] mx-auto px-6 h-full flex items-center justify-between gap-4 w-full">
|
||
{/* Left: Brand Glyph & Horizontal Navigation Links */}
|
||
<div className="flex items-center gap-6 sm:gap-10 h-full">
|
||
<div
|
||
className="w-8 h-8 rounded-lg bg-white flex items-center justify-center shrink-0 cursor-pointer shadow-sm hover:opacity-90 transition-opacity"
|
||
onClick={() => setActiveTab('dashboard')}
|
||
title="Dashboard Home"
|
||
>
|
||
<InfinityIcon className="w-5 h-5 text-black -rotate-[30deg]" strokeWidth={2.5} />
|
||
</div>
|
||
|
||
{/* Nav Tabs */}
|
||
<nav className="hidden md:flex items-center gap-6 h-full">
|
||
{navTabs.map(tab => {
|
||
const isActive = activeTab === tab.id;
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
onClick={() => setActiveTab(tab.id)}
|
||
className={`relative h-full flex items-center text-sm transition-colors cursor-pointer ${
|
||
isActive
|
||
? 'font-bold text-[#F1F1F6]'
|
||
: 'font-medium text-[#9A95B0] hover:text-[#F1F1F6]'
|
||
}`}
|
||
>
|
||
<span>{tab.label}</span>
|
||
{isActive && (
|
||
<span className="absolute bottom-0 left-0 right-0 h-[3px] bg-[#8B5CF6] rounded-t-[2px]" />
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
</div>
|
||
|
||
{/* Right: Search, Notifications Bell, User Menu */}
|
||
<div className="flex items-center gap-3 sm:gap-4">
|
||
{/* Search Input */}
|
||
<div className="relative hidden sm:block w-48 md:w-64">
|
||
<Search className="w-4 h-4 text-[#9A95B0] 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-[#181622] border border-white/[0.06] focus:border-[#8B5CF6]/50 rounded-xl pl-9 pr-3 py-1.5 text-xs text-[#F1F1F6] placeholder-[#9A95B0] focus:outline-none transition-colors"
|
||
/>
|
||
{searchQuery && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setSearchQuery('')}
|
||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[#9A95B0] 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-[#181622] border border-white/[0.06] text-[#9A95B0] hover:text-[#F1F1F6] transition-colors cursor-pointer disabled:opacity-50"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 ${isRefreshingApplicantData ? 'animate-spin text-[#8B5CF6]' : ''}`} />
|
||
</button>
|
||
|
||
{/* Notifications Bell */}
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowBellDropdown(!showBellDropdown);
|
||
setShowUserDropdown(false);
|
||
}}
|
||
className="relative p-2 rounded-xl bg-[#181622] border border-white/[0.06] text-[#9A95B0] hover:text-[#F1F1F6] 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-[#8B5CF6] text-white text-[10px] font-bold ring-2 ring-[#0F0E17]">
|
||
{unreadCount > 9 ? '9+' : unreadCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
{/* Bell Dropdown */}
|
||
{showBellDropdown && (
|
||
<div className="absolute right-0 mt-2 w-80 rounded-2xl bg-[#181622] border border-white/[0.08] shadow-2xl 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-[#F1F1F6]">Notifications</span>
|
||
{unreadCount > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => markAllAsRead()}
|
||
className="text-[11px] text-[#8B5CF6] 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-[#9A95B0]">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-[#F1F1F6] truncate">{item.subject}</p>
|
||
<p className="text-[11px] text-[#9A95B0] 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-[#8B5CF6] 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-[#181622] 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-[#8B5CF6] to-indigo-600 flex items-center justify-center text-white text-[11px] font-bold">
|
||
{getInitials(user?.profile?.name || user?.profile?.email || 'Partner')}
|
||
</div>
|
||
<span className="text-xs font-medium text-[#F1F1F6] hidden md:inline max-w-[120px] truncate">
|
||
{user?.profile?.name || 'Partner Account'}
|
||
</span>
|
||
<ChevronDown className="w-3.5 h-3.5 text-[#9A95B0]" />
|
||
</button>
|
||
|
||
{showUserDropdown && (
|
||
<div className="absolute right-0 mt-2 w-56 rounded-2xl bg-[#181622] border border-white/[0.08] shadow-2xl p-2 z-50 text-xs">
|
||
<div className="px-3 py-2 border-b border-white/[0.06]">
|
||
<p className="font-semibold text-[#F1F1F6] truncate">{user?.profile?.name || 'Partner User'}</p>
|
||
<p className="text-[11px] text-[#9A95B0] truncate">{user?.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>
|
||
|
||
{/* =====================================================================
|
||
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-[#8B5CF6] animate-spin" />
|
||
<p className="text-sm text-[#9A95B0]">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-[#F1F1F6]">
|
||
Ongoing Projects
|
||
</h2>
|
||
<p className="text-xs text-[#9A95B0] 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-[#181622] border border-white/[0.06] rounded-xl px-3 pr-8 py-2 text-xs font-medium text-[#F1F1F6] focus:outline-none cursor-pointer transition-colors"
|
||
>
|
||
<option value="date_added" className="bg-[#181622] text-[#F1F1F6]">Sort by date added</option>
|
||
<option value="grant_amount" className="bg-[#181622] text-[#F1F1F6]">Sort by award value</option>
|
||
<option value="days_left" className="bg-[#181622] text-[#F1F1F6]">Sort by days remaining</option>
|
||
<option value="name" className="bg-[#181622] text-[#F1F1F6]">Sort by project name</option>
|
||
</select>
|
||
<ChevronDown className="w-3.5 h-3.5 text-[#9A95B0] absolute right-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-[#181622] border border-white/[0.06] p-8 sm:p-12 shadow-sm flex flex-col items-center justify-center text-center relative overflow-hidden">
|
||
<div className="relative mb-5">
|
||
<div className="w-14 h-14 rounded-2xl bg-[#8B5CF6]/15 border border-[#8B5CF6]/25 text-[#8B5CF6] flex items-center justify-center shadow-lg shadow-[#8B5CF6]/10">
|
||
<Sparkles className="w-7 h-7" />
|
||
</div>
|
||
</div>
|
||
|
||
<h3 className="text-[20px] font-bold text-[#F1F1F6] tracking-tight">
|
||
{searchQuery ? 'No Matching Projects Found' : 'No Active Projects Assigned Yet'}
|
||
</h3>
|
||
|
||
<p className="text-[14px] leading-[22px] font-normal text-[#9A95B0] max-w-lg mt-2">
|
||
{searchQuery
|
||
? `We couldn't find any ongoing projects matching "${searchQuery}". Clear your search query to see your full workspace portfolio.`
|
||
: "Welcome to your Partner Workspace! Your funding organisation hasn't assigned any active project records to your profile just yet. If your funding award was recently approved or updated, click below to sync and retrieve your latest project schedule."}
|
||
</p>
|
||
|
||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||
{searchQuery && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setSearchQuery('')}
|
||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-[#F1F1F6] text-xs font-semibold transition-all cursor-pointer"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
<span>Clear Search</span>
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => refetchApplicantData()}
|
||
disabled={isRefreshingApplicantData}
|
||
className="inline-flex items-center gap-2.5 px-6 py-2.5 rounded-xl bg-[#8B5CF6] hover:bg-[#7C3AED] text-white font-semibold text-xs tracking-wide shadow-lg shadow-[#8B5CF6]/25 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-[#F1F1F6]">Messages & Communications</h1>
|
||
<p className="text-[12px] text-[#9A95B0] mt-0.5">
|
||
Direct communication with your funding programme officers.
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setContactTarget({
|
||
organisation: partnerSummary.primaryFundingOrg,
|
||
projectTitle: 'Partner Support',
|
||
});
|
||
setMessageSubject('');
|
||
setMessageBody('');
|
||
}}
|
||
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-[#8B5CF6] hover:bg-[#7C3AED] text-white font-semibold text-xs shadow-lg shadow-[#8B5CF6]/20 transition-all cursor-pointer"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
<span>New Message</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="rounded-[16px] bg-[#181622] border border-white/[0.06] divide-y divide-white/[0.06] overflow-hidden">
|
||
{inboxNotifications.length === 0 ? (
|
||
<div className="p-12 text-center text-[#9A95B0] text-sm">
|
||
No new messages in your inbox yet. Messages sent by programme managers will be displayed here.
|
||
</div>
|
||
) : (
|
||
inboxNotifications.map(msg => {
|
||
const isUnread = Number(msg.status) !== NotificationStatus.Read;
|
||
return (
|
||
<div
|
||
key={msg.id}
|
||
onClick={() => {
|
||
markAsRead(msg.id);
|
||
setSelectedNotification(msg);
|
||
}}
|
||
className="p-5 hover:bg-white/[0.03] transition-colors flex items-start justify-between gap-4 cursor-pointer"
|
||
>
|
||
<div className="flex items-start gap-4 min-w-0">
|
||
<div className="h-10 w-10 rounded-full bg-[#8B5CF6]/15 border border-[#8B5CF6]/30 text-[#8B5CF6] font-bold text-xs flex items-center justify-center shrink-0">
|
||
{getInitials(msg.subject || 'PFM')}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-sm font-semibold text-[#F1F1F6] truncate">
|
||
{msg.subject || 'Programme Notification'}
|
||
</h3>
|
||
{isUnread && (
|
||
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-[#EF4444] text-white">
|
||
New
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-[#9A95B0] mt-1 line-clamp-2">{stripMarkdown(msg.message || '')}</p>
|
||
</div>
|
||
</div>
|
||
<span className="text-xs text-[#9A95B0]/70 shrink-0">
|
||
{formatFullDate(msg.createdAt)}
|
||
</span>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : activeTab === 'contracts' ? (
|
||
/* ===================================================================
|
||
CONTRACTS & AWARDS TAB
|
||
=================================================================== */
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Contracts & Approved Awards</h1>
|
||
<p className="text-[12px] text-[#9A95B0] mt-0.5">
|
||
Review your approved funding awards and download or sign grant agreements.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
{partnerSummary.contractEntries.length === 0 ? (
|
||
<div className="col-span-full rounded-[16px] bg-[#181622] border border-white/[0.06] p-10 text-center text-sm text-[#9A95B0]">
|
||
No contracts have been issued to your organisation yet.
|
||
</div>
|
||
) : (
|
||
partnerSummary.contractEntries.map(({ contract, award, fundingOrganisation }) => {
|
||
const isExecuted = Number(contract.status) === ContractStatus.Executed;
|
||
const contractVer = (contract as any).version || 1;
|
||
return (
|
||
<div
|
||
key={contract.id}
|
||
className="rounded-[16px] bg-[#181622] border border-white/[0.06] p-6 flex flex-col justify-between gap-5"
|
||
>
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="text-xs font-semibold text-[#8B5CF6] uppercase tracking-wider">
|
||
Agreement v{contractVer}
|
||
</span>
|
||
<span
|
||
className={`px-2.5 py-0.5 rounded-full text-xs font-semibold ${
|
||
isExecuted
|
||
? 'bg-emerald-500/15 text-emerald-300 border border-emerald-500/30'
|
||
: 'bg-[#F97316]/15 text-[#F97316] border border-[#F97316]/30'
|
||
}`}
|
||
>
|
||
{isExecuted ? 'Signed & Executed' : 'Action Required: Review & Sign'}
|
||
</span>
|
||
</div>
|
||
<h3 className="text-lg font-bold text-[#F1F1F6]">
|
||
{fundingOrganisation?.name || 'Grant Funding Agreement'}
|
||
</h3>
|
||
<div className="grid grid-cols-2 gap-4 pt-2 text-xs">
|
||
<div>
|
||
<p className="text-[#9A95B0]">Contract Value</p>
|
||
<p className="text-sm font-bold text-[#F1F1F6] mt-0.5">
|
||
{formatZAR(contract.totalValue || award?.amount || 0)}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-[#9A95B0]">Valid Period</p>
|
||
<p className="text-[#D1CBDD] font-medium mt-0.5">
|
||
{formatShortDate(contract.effectiveAt)} – {formatFullDate(contract.expiresAt)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-3 pt-4 border-t border-white/[0.06]">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleOpenSecureFile('contract', contract.id)}
|
||
className="flex-1 py-2.5 px-4 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-[#F1F1F6] text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer"
|
||
>
|
||
<ExternalLink className="w-3.5 h-3.5" />
|
||
<span>Download PDF</span>
|
||
</button>
|
||
{!isExecuted && canAdd('grant_financials') && (
|
||
<button
|
||
type="button"
|
||
onClick={() => signContractMutation.mutate(contract)}
|
||
disabled={signContractMutation.isPending}
|
||
className="flex-1 py-2.5 px-4 rounded-xl bg-[#8B5CF6] hover:bg-[#7C3AED] text-white text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer disabled:opacity-50"
|
||
>
|
||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||
<span>{signContractMutation.isPending ? 'Signing...' : 'Accept & Sign'}</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : activeTab === 'analytics' ? (
|
||
/* ===================================================================
|
||
ANALYTICS TAB (Zero Mock Data: Real API Derived Values)
|
||
=================================================================== */
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Portfolio Analytics</h1>
|
||
<p className="text-[12px] text-[#9A95B0] mt-0.5">
|
||
Real-time performance metrics and deliverable fulfillment rates across your assigned projects.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||
{/* Card 1: Milestone Fulfillment Rate */}
|
||
<div className="p-6 rounded-[16px] bg-[#181622] border border-white/[0.06]">
|
||
<div className="flex items-center justify-between text-[#8B5CF6] mb-2">
|
||
<TrendingUp className="w-5 h-5" />
|
||
<span className="text-xs font-semibold text-emerald-400">
|
||
{portfolioStats.completedMilestones} of {portfolioStats.totalMilestones} Milestones
|
||
</span>
|
||
</div>
|
||
<p className="text-2xl font-bold text-[#F1F1F6]">{portfolioStats.milestoneRate}%</p>
|
||
<p className="text-xs text-[#9A95B0] mt-1">Milestone Completion Rate</p>
|
||
</div>
|
||
|
||
{/* Card 2: Active Projects */}
|
||
<div className="p-6 rounded-[16px] bg-[#181622] border border-white/[0.06]">
|
||
<div className="flex items-center justify-between text-teal-400 mb-2">
|
||
<Briefcase className="w-5 h-5" />
|
||
<span className="text-xs font-semibold text-[#8B5CF6]">Active</span>
|
||
</div>
|
||
<p className="text-2xl font-bold text-[#F1F1F6]">{portfolioStats.totalProjects} Projects</p>
|
||
<p className="text-xs text-[#9A95B0] mt-1">Assigned Funded Projects</p>
|
||
</div>
|
||
|
||
{/* Card 3: Total Supporting Proofs */}
|
||
<div className="p-6 rounded-[16px] bg-[#181622] border border-white/[0.06]">
|
||
<div className="flex items-center justify-between text-amber-400 mb-2">
|
||
<AwardIcon className="w-5 h-5" />
|
||
<span className="text-xs font-semibold text-amber-400">Audited</span>
|
||
</div>
|
||
<p className="text-2xl font-bold text-[#F1F1F6]">{portfolioStats.totalEvidenceCount} Documents</p>
|
||
<p className="text-xs text-[#9A95B0] mt-1">Uploaded Evidence Deliverables</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* ===================================================================
|
||
DOCUMENTS & DELIVERABLES TAB
|
||
=================================================================== */
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Deliverables & Evidence Documents</h1>
|
||
<p className="text-[12px] text-[#9A95B0] mt-0.5">
|
||
Invoices, reports, and verified deliverable proofs submitted across projects.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="rounded-[16px] bg-[#181622] border border-white/[0.06] overflow-hidden">
|
||
{enrichedProjects.flatMap(p => p.evidence.map(ev => ({ ev, project: p.project }))).length === 0 ? (
|
||
<div className="p-12 text-center space-y-3">
|
||
<p className="text-sm text-[#9A95B0]">
|
||
No documents uploaded yet. 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-[#8B5CF6]">
|
||
<FileText className="w-5 h-5" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-semibold text-[#F1F1F6] truncate">
|
||
{EVIDENCE_TYPE_LABELS[evType] || 'Supporting Document'}{' '}
|
||
<span className="text-xs font-normal text-[#9A95B0]">
|
||
(v{ev.version || 1})
|
||
</span>
|
||
</p>
|
||
<p className="text-xs text-[#9A95B0] truncate mt-0.5">
|
||
Project: {project.name} • Submitted {formatFullDate(evDate)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleOpenSecureFile('evidence', ev.id)}
|
||
className="px-3.5 py-2 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-xs font-semibold text-[#8B5CF6] flex items-center gap-1.5 shrink-0 cursor-pointer"
|
||
>
|
||
<ExternalLink className="w-3.5 h-3.5" />
|
||
<span>View File</span>
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</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
|
||
===================================================================== */}
|
||
{contactTarget && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-md p-4">
|
||
<div className="w-full max-w-lg rounded-2xl bg-[#0d1322] border border-white/10 shadow-2xl p-6 space-y-4">
|
||
<div className="flex items-center justify-between border-b border-white/[0.08] pb-3">
|
||
<div className="flex items-center gap-2">
|
||
<MessageSquare className="w-5 h-5 text-indigo-400" />
|
||
<h3 className="text-base font-bold text-white">Send Message</h3>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setContactTarget(null)}
|
||
className="p-1 rounded-lg text-slate-400 hover:text-white cursor-pointer"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSendContactMessage} className="space-y-4">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-300 mb-1">Recipient</label>
|
||
<input
|
||
type="text"
|
||
disabled
|
||
value={`${contactTarget.organisation?.name || 'Programme Office'} (${contactTarget.projectTitle || 'Enquiry'})`}
|
||
className="w-full px-3.5 py-2 rounded-xl bg-slate-900 border border-white/10 text-xs text-slate-300"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-300 mb-1">Subject</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={messageSubject}
|
||
onChange={e => setMessageSubject(e.target.value)}
|
||
placeholder="How can the funding team help you?"
|
||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs text-white focus:outline-none focus:border-indigo-400"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-300 mb-1">Message</label>
|
||
<textarea
|
||
rows={4}
|
||
required
|
||
value={messageBody}
|
||
onChange={e => setMessageBody(e.target.value)}
|
||
placeholder="Write your message here..."
|
||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs text-white focus:outline-none focus:border-indigo-400"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-end gap-3 pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setContactTarget(null)}
|
||
className="px-4 py-2 rounded-xl bg-slate-800 text-slate-300 text-xs font-semibold cursor-pointer"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={isSending}
|
||
className="px-5 py-2 rounded-xl bg-indigo-500 hover:bg-indigo-400 text-white font-semibold text-xs flex items-center gap-2 cursor-pointer disabled:opacity-50"
|
||
>
|
||
<Send className="w-3.5 h-3.5" />
|
||
<span>{isSending ? 'Sending...' : 'Send Message'}</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* =====================================================================
|
||
MESSAGE READER MODAL
|
||
===================================================================== */}
|
||
{selectedNotification && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-md p-4 animate-in fade-in duration-200">
|
||
<div className="w-full max-w-lg rounded-2xl bg-[#0d1322] border border-white/10 shadow-2xl p-6 space-y-4">
|
||
<div className="flex items-center justify-between border-b border-white/[0.08] pb-3">
|
||
<h3 className="text-base font-bold text-white truncate max-w-[360px]">
|
||
{selectedNotification.subject || 'Message Details'}
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedNotification(null)}
|
||
className="p-1 rounded-lg text-slate-400 hover:text-white cursor-pointer"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-3 text-xs">
|
||
<div className="flex items-center justify-between text-[#9A95B0]">
|
||
<span>From: {selectedNotification.destination?.split('|')[0] || 'Programme Management Office'}</span>
|
||
<span>{formatFullDate(selectedNotification.createdAt)}</span>
|
||
</div>
|
||
<div className="p-4 rounded-xl bg-slate-950/60 border border-white/5 text-slate-200 whitespace-pre-wrap leading-relaxed max-h-80 overflow-y-auto">
|
||
<MessageViewer
|
||
message={selectedNotification.message || ''}
|
||
subject={selectedNotification.subject || undefined}
|
||
createdDate={selectedNotification.createdAt}
|
||
platform={selectedNotification.platform}
|
||
variant="card"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedNotification(null)}
|
||
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold cursor-pointer"
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|