import React, { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Project, Award, Milestone, Activity, Evidence, Contract, Organisation, ApprovalStatus } from '../../services/api/models'; import { encodeHashIdSync, decodeHashIdSync } from '../../services/toolsService'; import { toIntegerId, getMilestones, getActivities, getProjectEvidence, submitActivityForApproval } from '../../services/api'; import { getEvidenceTypeLabel, getIndustrySectorLabel, getProjectTypeLabel } from '../../lib/formatters'; import { EvidenceUploadModal } from './EvidenceUploadModal'; import { GovernanceInfoModal } from './GovernanceInfoModal'; import { FolderKanban, AlertTriangle, ShieldAlert, Scale, ChevronDown, ChevronUp, Calendar, Landmark, Award as AwardIcon, Layers, CheckCircle2, Clock, FileText, UploadCloud, ExternalLink, ShieldCheck, DollarSign, Paperclip, Building, Check, Send, Loader2, Info, RefreshCw, Plus } from 'lucide-react'; export interface EnrichedPartnerProject { project: Project; award: Award | null; contract: Contract | null; fundingOrganisation: Organisation | null; invitedOrganisation: Organisation | null; milestones: Milestone[]; evidence: Evidence[]; activities: Activity[]; disbursements: any[]; } interface ProjectCardProps { item: EnrichedPartnerProject; isExpanded?: boolean; onToggleExpand?: () => void; onUploadEvidence?: (target: any) => void; onSubmitActivityForApproval?: ( activity: Activity, milestone?: Milestone | null, evidences?: Evidence[] ) => Promise; onOpenSecureFile: (type: 'contract' | 'evidence', id: string | number) => void; } export const ProjectCard: React.FC = ({ item, isExpanded: externalIsExpanded, onToggleExpand, onOpenSecureFile, }) => { const [internalExpanded, setInternalExpanded] = useState(false); const isExpanded = externalIsExpanded !== undefined ? externalIsExpanded : internalExpanded; const toggleExpand = onToggleExpand || (() => setInternalExpanded(prev => !prev)); const [submittingActivityId, setSubmittingActivityId] = useState(null); // Evidence upload modal state const [uploadTarget, setUploadTarget] = useState<{ project: Project; milestone?: Milestone | null; activity: Activity; } | null>(null); const [governanceTarget, setGovernanceTarget] = useState<{ project: Project; milestone?: Milestone | null; activity?: Activity | null; initialTab?: 'compliance' | 'beneficiaries' | 'risks' | 'rules'; } | null>(null); const { project, award, contract, fundingOrganisation, invitedOrganisation, disbursements } = item; const hashedProjectId = encodeHashIdSync(project.id); // Financial calculations const grantAmount = contract?.totalValue || award?.amount || (project as any).budget || 0; const disbursedAmount = (disbursements || []) .filter(d => Number(d.status) === ApprovalStatus.Approved) .reduce((sum, d) => sum + Number(d.amount || 0), 0); // Calculate days remaining const calculateDaysLeft = (targetDate?: string | null): string => { if (!targetDate) return 'Open'; const diff = new Date(targetDate).getTime() - Date.now(); const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); return days > 0 ? `${days} days` : 'Completed'; }; const daysLeft = calculateDaysLeft(project.endedAt || contract?.expiresAt); // Currency formatter const formatZAR = (amount?: number | string | null): string => { if (amount === undefined || amount === null || amount === '') return 'R 0.00'; const num = typeof amount === 'number' ? amount : parseFloat(String(amount)); if (isNaN(num)) return 'R 0.00'; return `R ${num.toLocaleString('en-ZA', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; }; // Date formatter const formatDate = (dateStr?: string | null): string => { if (!dateStr) return 'TBD'; try { return new Date(dateStr).toLocaleDateString('en-ZA', { day: '2-digit', month: 'short', year: 'numeric' }); } catch { return String(dateStr); } }; // Date & Time formatter const formatDateTime = (dateStr?: string | null): string => { if (!dateStr) return 'TBD'; try { const d = new Date(dateStr); return `${d.toLocaleDateString('en-ZA', { day: '2-digit', month: 'short', year: 'numeric' })} ${d.toLocaleTimeString('en-ZA', { hour: '2-digit', minute: '2-digit' })}`; } catch { return String(dateStr); } }; // Status Badge Helper using Landing Page emerald/teal/amber color palette const renderStatusBadge = (status?: any, fallback = 'In Progress') => { const num = Number(status); if (num === ApprovalStatus.Approved || String(status).toLowerCase().includes('approved')) { return ( Approved ); } if (num === ApprovalStatus.Review || String(status).toLowerCase().includes('review')) { return ( Under Review ); } if (num === ApprovalStatus.Pending || String(status).toLowerCase().includes('pending')) { return ( In Progress ); } const label = (status !== undefined && status !== null && !isNaN(num)) ? fallback : String(status || fallback); return ( {label} ); }; // On-demand fetch when expanded const { data: projectDetails, isLoading: isLoadingDetails, isFetching: isFetchingDetails, refetch: refetchProjectData, } = useQuery({ queryKey: ['project-milestones-activities-evidence', String(project.id)], queryFn: async () => { const pid = project.id; const [milestonesRes, activitiesRes, evidenceRes] = await Promise.all([ getMilestones(pid).catch((err) => { console.warn('Failed to load milestones for project', pid, err); return [] as Milestone[]; }), getActivities(pid).catch((err) => { console.warn('Failed to load activities for project', pid, err); return [] as Activity[]; }), getProjectEvidence(pid).catch((err) => { console.warn('Failed to load evidence for project', pid, err); return [] as Evidence[]; }), ]); return { milestones: milestonesRes || [], activities: activitiesRes || [], evidence: evidenceRes || [], }; }, enabled: isExpanded, staleTime: 1000 * 20, }); const effectiveMilestones: Milestone[] = projectDetails?.milestones ?? (item.milestones?.length ? item.milestones : []); const effectiveActivities: Activity[] = projectDetails?.activities ?? (item.activities?.length ? item.activities : []); const effectiveEvidence: Evidence[] = projectDetails?.evidence ?? (item.evidence?.length ? item.evidence : []); // Sort Milestones according to Start Date then Due Date const sortedMilestones = [...effectiveMilestones].sort((a, b) => { const startA = new Date(a.createdAt || a.dueAt || 0).getTime(); const startB = new Date(b.createdAt || b.dueAt || 0).getTime(); if (startA !== startB) return startA - startB; return new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime(); }); // Requirement #5: Milestone and Project Late / At Risk calculations const nowTime = Date.now(); const hasOverdueMilestone = effectiveMilestones.some(m => { const d = m.dueAt ? new Date(m.dueAt).getTime() : null; const fulfilled = Number(m.status) === ApprovalStatus.Approved; return d !== null && d < nowTime && !fulfilled; }); const hasAtRiskMilestone = !hasOverdueMilestone && effectiveMilestones.some(m => { const d = m.dueAt ? new Date(m.dueAt).getTime() : null; const fulfilled = Number(m.status) === ApprovalStatus.Approved; return d !== null && d >= nowTime && (d - nowTime <= 3 * 24 * 60 * 60 * 1000) && !fulfilled; }); const getEvidenceForActivity = (activityId: string | number): Evidence[] => { const targetNum = toIntegerId(activityId); return effectiveEvidence .filter(e => toIntegerId(e.activityId) === targetNum) .sort((e1, e2) => new Date(e2.createdAt || 0).getTime() - new Date(e1.createdAt || 0).getTime()); }; const getActivitiesForMilestone = (milestoneId: string | number): Activity[] => { const targetMilestoneNum = toIntegerId(milestoneId); return effectiveActivities .filter(a => toIntegerId(a.milestoneId) === targetMilestoneNum) .sort((a, b) => { const dateA = new Date(a.performedAt || a.createdAt || 0).getTime(); const dateB = new Date(b.performedAt || b.createdAt || 0).getTime(); return dateA - dateB; }); }; const unassignedActivities = effectiveActivities .filter(a => !a.milestoneId || toIntegerId(a.milestoneId) === 0) .sort((a, b) => { const dateA = new Date(a.performedAt || a.createdAt || 0).getTime(); const dateB = new Date(b.performedAt || b.createdAt || 0).getTime(); return dateA - dateB; }); const totalActivitiesCount = effectiveActivities.length; const fulfilledActivitiesCount = effectiveActivities.filter(a => { const evs = getEvidenceForActivity(a.id); return evs.length > 0 || Number(a.status) === ApprovalStatus.Approved || Number(a.status) === ApprovalStatus.Review; }).length; const progressPercent = totalActivitiesCount > 0 ? Math.round((fulfilledActivitiesCount / totalActivitiesCount) * 100) : (effectiveMilestones.length > 0 ? 0 : 100); const handleSubmitActivity = async (activity: Activity, milestone?: Milestone | null) => { const evs = getEvidenceForActivity(activity.id); setSubmittingActivityId(activity.id); try { await submitActivityForApproval(activity, project.id, evs); await refetchProjectData(); } catch (err) { console.error(err); } finally { setSubmittingActivityId(null); } }; // Enum String Descriptions (Requirement #3) const sectorDescription = getIndustrySectorLabel(project.sector); const projectTypeDescription = getProjectTypeLabel(project.projectType); return (
{/* ========================================================================= TOP PORTION: FULL-WIDTH HEADER, METADATA & METRICS (Landing Page Theme) ========================================================================= */}
{/* Row 1: Badges & Project Title */}
{hashedProjectId && ( #{hashedProjectId} )} {hasOverdueMilestone ? ( Late (Overdue Milestone) ) : hasAtRiskMilestone ? ( At Risk (Milestone Due Soon) ) : ( renderStatusBadge(project.status, 'Active Implementation') )} {/* Requirement #6: Informational Compliance & Risk Modal Trigger */} {/* Requirement #3: Display enum string description instead of integer value */} {sectorDescription && ( Sector: {sectorDescription} )} {projectTypeDescription && ( Type: {projectTypeDescription} )}
{/* Toggle Expand Button in Header */}
{/* Row 2: Title and Description */}

{project.name || `Project #${hashedProjectId}`}

{project.description && (

{project.description}

)}
{/* Row 3: Rest of Applicant Data (Funder, Recipient Org, Linked Contract & Award) */}
{/* Funder */}
Funding Organisation {fundingOrganisation?.name || 'Programme Office'}
{/* Recipient Organisation */}
Partner Recipient {invitedOrganisation?.name || 'Assigned Partner'}
{/* Agreement / Contract */}
Grant Agreement {contract ? ( ) : ( Standard Award )}
{/* Award Commitment */}
Funding Award {award ? `Award #${encodeHashIdSync(award.id)}` : 'Approved Award'} ({formatZAR(grantAmount)})
{/* Row 4: Metrics Inline Row (Award, Spend, Timeline, Days Left, Evidence Progress) */}
{/* Total Award */}
{formatZAR(grantAmount)} Award
{/* Spend Disbursed */}
{formatZAR(disbursedAmount)} Disbursed
{/* Timeline */}
{formatDate(project.startedAt || contract?.effectiveAt)} – {formatDate(project.endedAt || contract?.expiresAt)} ({daysLeft})
{/* Deliverables Count */}
{effectiveMilestones.length} Milestones • {totalActivitiesCount} Activities
{/* Docs Submitted */}
{effectiveEvidence.length} Uploaded Proofs
{/* Evidence Completion Progress Pill */}
Fulfillment: {progressPercent}%
{/* ========================================================================= INLINE EXPANDED SECTION: MILESTONES & ACTIVITIES (Landing Page Palette) ========================================================================= */} {isExpanded && (
{/* Section Sub-header & Guidance */}

Project Milestones & Contractual Activities {isFetchingDetails && ( )}

All milestones are enabled. Complete activities by uploading supporting evidence documents, then submit for review and approval by organisation staff.

Fulfillment Only
{/* LOADING STATE WHEN VIEW DETAILS IS FIRST CLICKED */} {isLoadingDetails ? (
Fetching project milestones, activities, and evidence...
) : sortedMilestones.length === 0 ? (

No contractual milestones recorded for this project yet.

) : ( /* MILESTONES LISTING (Sorted by Start Date then Due Date) */
{sortedMilestones.map((milestone, mIdx) => { const milestoneActs = getActivitiesForMilestone(milestone.id); const milestoneFulfilledActs = milestoneActs.filter(a => { const evs = getEvidenceForActivity(a.id); return evs.length > 0 || Number(a.status) === ApprovalStatus.Approved || Number(a.status) === ApprovalStatus.Review; }); const isMilestoneFullyFulfilled = (Number(milestone.status) === ApprovalStatus.Approved) || (milestoneActs.length > 0 && milestoneFulfilledActs.length === milestoneActs.length); const actualMilestoneId = decodeHashIdSync(milestone.id); const dueTime = milestone.dueAt ? new Date(milestone.dueAt).getTime() : null; const isMilestoneOverdue = dueTime !== null && dueTime < nowTime && !isMilestoneFullyFulfilled; const isMilestoneAtRisk = dueTime !== null && !isMilestoneOverdue && !isMilestoneFullyFulfilled && (dueTime - nowTime <= 3 * 24 * 60 * 60 * 1000); return (
{/* Milestone Header */}
Milestone #{mIdx + 1} (ID: #{encodeHashIdSync(milestone.id)}) {/* Requirement #3: Decode hashId to show actual milestone ID */} Actual ID: #{actualMilestoneId} {/* Requirement #5: Milestone late and at risk badges */} {isMilestoneOverdue ? ( Late ) : isMilestoneAtRisk ? ( At Risk (Due Soon) ) : ( renderStatusBadge(milestone.status, 'In Progress') )} {isMilestoneFullyFulfilled && ( Milestone Fulfilled )} {/* Requirement #6: Milestone level Rules & Risks trigger */}
{milestone.name || `Milestone Target #${mIdx + 1}`}
Start Date {formatDate(milestone.createdAt)}
Due Date {formatDate(milestone.dueAt)}
{milestoneFulfilledActs.length} / {milestoneActs.length} Fulfilled
{/* NESTED ACTIVITIES LISTING */}
Activities Under Milestone ({milestoneActs.length}) Sorted by execution date
{milestoneActs.length === 0 ? (
No specific activities defined under this milestone.
) : (
{milestoneActs.map((act) => { const actEvidences = getEvidenceForActivity(act.id); const hasEvidence = actEvidences.length > 0; const isApproved = Number(act.status) === ApprovalStatus.Approved; const isUnderReview = Number(act.status) === ApprovalStatus.Review || actEvidences.some(e => Number(e.status) === ApprovalStatus.Review); return (
{/* Activity Details Row */}
{act.title || `Activity #${encodeHashIdSync(act.id)}`} {act.category && ( {act.category} )} {isApproved ? ( Approved by Staff ) : isUnderReview ? ( Submitted • Staff Review Pending ) : hasEvidence ? ( {actEvidences.length} Proof{actEvidences.length > 1 ? 's' : ''} Attached (Ready) ) : ( Evidence Required )}
{act.description && (

{act.description}

)}
Budget: {formatZAR(act.amount)} Target Date: {formatDate(act.performedAt || act.createdAt)}
{/* Action Buttons: Add Evidence + Submit for Approval */}
{!isApproved && !isUnderReview && ( )}
{/* LIST EXISTING EVIDENCE UNDER MATCHING ACTIVITY */} {actEvidences.length > 0 && (
Attached Proof Documents ({actEvidences.length}): Sorted by upload time
{actEvidences.map((ev) => (

{(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}

{getEvidenceTypeLabel(ev.type)} {' '} • {formatDateTime(ev.createdAt)}

{renderStatusBadge(ev.status, 'Pending Review')}
))}
)}
); })}
)}
); })}
)} {/* UNASSIGNED ACTIVITIES */} {unassignedActivities.length > 0 && (
General Project Activities

Activities not bound to a specific milestone. Fulfill by submitting required documents.

{unassignedActivities.map((act) => { const actEvidences = getEvidenceForActivity(act.id); const hasEvidence = actEvidences.length > 0; const isApproved = Number(act.status) === ApprovalStatus.Approved; const isUnderReview = Number(act.status) === ApprovalStatus.Review || actEvidences.some(e => Number(e.status) === ApprovalStatus.Review); return (
{act.title || `Activity #${encodeHashIdSync(act.id)}`} {renderStatusBadge(act.status, 'Pending Evidence')}
{act.description && (

{act.description}

)}
Budget: {formatZAR(act.amount)} Target Date: {formatDate(act.performedAt || act.createdAt)}
{!isApproved && !isUnderReview && ( )}
{actEvidences.length > 0 && (
{actEvidences.map((ev) => (
{(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`} {getEvidenceTypeLabel(ev.type)} • {formatDateTime(ev.createdAt)}
))}
)}
); })}
)}
)} {/* EVIDENCE UPLOAD MODAL */} {uploadTarget && ( setUploadTarget(null)} project={uploadTarget.project} milestone={uploadTarget.milestone} activity={uploadTarget.activity} onEvidenceUploaded={async () => { await refetchProjectData(); setUploadTarget(null); }} /> )} {/* COMPLIANCE & RISKS INFORMATIONAL MODAL (Requirement #6) */} {governanceTarget && ( setGovernanceTarget(null)} project={governanceTarget.project} milestone={governanceTarget.milestone} activity={governanceTarget.activity} initialTab={governanceTarget.initialTab} /> )}
); };