Files
pfm/PostFundManagement.PartnerUI/src/components/dashboard/ProjectCard.tsx
T

955 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void>;
onOpenSecureFile: (type: 'contract' | 'evidence', id: string | number) => void;
}
export const ProjectCard: React.FC<ProjectCardProps> = ({
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<string | number | null>(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 (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-950/80 border border-emerald-500/40 text-emerald-300 shadow-sm shadow-emerald-950/50">
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
<span>Approved</span>
</span>
);
}
if (num === ApprovalStatus.Review || String(status).toLowerCase().includes('review')) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-amber-950/80 border border-amber-500/40 text-amber-300 shadow-sm shadow-amber-950/50">
<Clock className="w-3 h-3 text-amber-400" />
<span>Under Review</span>
</span>
);
}
if (num === ApprovalStatus.Pending || String(status).toLowerCase().includes('pending')) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-teal-950/80 border border-teal-500/40 text-teal-300 shadow-sm shadow-teal-950/50">
<span className="w-1.5 h-1.5 rounded-full bg-teal-400 animate-pulse" />
<span>In Progress</span>
</span>
);
}
const label = (status !== undefined && status !== null && !isNaN(num)) ? fallback : String(status || fallback);
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-teal-950/80 border border-teal-500/40 text-teal-300 shadow-sm shadow-teal-950/50">
<span className="w-1.5 h-1.5 rounded-full bg-teal-400 animate-pulse" />
<span>{label}</span>
</span>
);
};
// 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 (
<div className="w-full rounded-2xl bg-slate-900/70 border border-white/10 hover:border-emerald-500/30 transition-all p-6 sm:p-7 shadow-xl shadow-black/20 backdrop-blur-xl">
{/* =========================================================================
TOP PORTION: FULL-WIDTH HEADER, METADATA & METRICS (Landing Page Theme)
========================================================================= */}
<div className="space-y-5">
{/* Row 1: Badges & Project Title */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<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">
<FolderKanban className="w-4 h-4" />
</div>
{hashedProjectId && (
<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">
#{hashedProjectId}
</span>
)}
{hasOverdueMilestone ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-rose-950/90 border border-rose-500/50 text-rose-300 shadow-sm shadow-rose-950/50">
<AlertTriangle className="w-3.5 h-3.5 text-rose-400" />
<span>Late (Overdue Milestone)</span>
</span>
) : hasAtRiskMilestone ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-950/90 border border-amber-500/50 text-amber-300 shadow-sm shadow-amber-950/50">
<Clock className="w-3.5 h-3.5 text-amber-400" />
<span>At Risk (Milestone Due Soon)</span>
</span>
) : (
renderStatusBadge(project.status, 'Active Implementation')
)}
{/* Requirement #6: Informational Compliance & Risk Modal Trigger */}
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setGovernanceTarget({ project, initialTab: 'compliance' });
}}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] border border-white/10 text-[11px] font-medium text-slate-300 hover:text-white transition-colors cursor-pointer"
title="View Project Compliance, Risks & Institutional Rules"
>
<ShieldAlert className="w-3.5 h-3.5 text-emerald-400" />
<span>Compliance & Risks</span>
</button>
{/* Requirement #3: Display enum string description instead of integer value */}
{sectorDescription && (
<span className="text-[11px] font-medium text-emerald-300 bg-emerald-950/60 border border-emerald-500/30 px-2.5 py-0.5 rounded-md">
Sector: {sectorDescription}
</span>
)}
{projectTypeDescription && (
<span className="text-[11px] font-medium text-teal-300 bg-teal-950/60 border border-teal-500/30 px-2.5 py-0.5 rounded-md">
Type: {projectTypeDescription}
</span>
)}
</div>
{/* Toggle Expand Button in Header */}
<button
type="button"
onClick={toggleExpand}
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)] shrink-0 active:scale-95"
>
<span>{isExpanded ? 'Hide Details' : 'View Details'}</span>
{isExpanded ? <ChevronUp className="w-4 h-4 text-emerald-400" /> : <ChevronDown className="w-4 h-4 text-emerald-400" />}
</button>
</div>
{/* Row 2: Title and Description */}
<div>
<h3 className="text-xl font-bold text-slate-100 tracking-tight">
{project.name || `Project #${hashedProjectId}`}
</h3>
{project.description && (
<p className="text-[14px] leading-[22px] text-slate-300 mt-1.5 max-w-4xl">
{project.description}
</p>
)}
</div>
{/* Row 3: Rest of Applicant Data (Funder, Recipient Org, Linked Contract & Award) */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 py-3 px-4 rounded-xl bg-slate-950/60 border border-white/5 text-xs">
{/* Funder */}
<div className="flex items-center gap-2.5">
<Landmark className="w-4 h-4 text-emerald-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-400 uppercase font-bold block">Funding Organisation</span>
<span className="text-slate-200 font-medium truncate block">
{fundingOrganisation?.name || 'Programme Office'}
</span>
</div>
</div>
{/* Recipient Organisation */}
<div className="flex items-center gap-2.5">
<Building className="w-4 h-4 text-teal-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-400 uppercase font-bold block">Partner Recipient</span>
<span className="text-slate-200 font-medium truncate block">
{invitedOrganisation?.name || 'Assigned Partner'}
</span>
</div>
</div>
{/* Agreement / Contract */}
<div className="flex items-center gap-2.5">
<FileText className="w-4 h-4 text-cyan-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-400 uppercase font-bold block">Grant Agreement</span>
{contract ? (
<button
type="button"
onClick={() => onOpenSecureFile('contract', contract.id)}
className="text-cyan-300 hover:text-cyan-200 font-medium underline flex items-center gap-1 cursor-pointer truncate"
title="View Contract Agreement PDF"
>
<span>Agreement #{encodeHashIdSync(contract.id)}</span>
<ExternalLink className="w-3 h-3 shrink-0" />
</button>
) : (
<span className="text-slate-400">Standard Award</span>
)}
</div>
</div>
{/* Award Commitment */}
<div className="flex items-center gap-2.5">
<AwardIcon className="w-4 h-4 text-amber-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-400 uppercase font-bold block">Funding Award</span>
<span className="text-amber-300 font-medium font-mono truncate block">
{award ? `Award #${encodeHashIdSync(award.id)}` : 'Approved Award'} ({formatZAR(grantAmount)})
</span>
</div>
</div>
</div>
{/* Row 4: Metrics Inline Row (Award, Spend, Timeline, Days Left, Evidence Progress) */}
<div className="flex flex-wrap items-center justify-between gap-4 pt-2 border-t border-white/5 text-xs">
<div className="flex flex-wrap items-center gap-5 sm:gap-7">
{/* Total Award */}
<div className="flex items-center gap-1.5 font-medium text-slate-300">
<span className="p-1 rounded-md bg-emerald-500/10 text-emerald-400 flex items-center justify-center">
<DollarSign className="w-3.5 h-3.5" />
</span>
<span className="font-bold text-slate-100 font-mono">{formatZAR(grantAmount)}</span>
<span className="text-slate-400">Award</span>
</div>
{/* Spend Disbursed */}
<div className="flex items-center gap-1.5 font-medium text-slate-300">
<span className="p-1 rounded-md bg-teal-500/10 text-teal-400 flex items-center justify-center">
<DollarSign className="w-3.5 h-3.5" />
</span>
<span className="font-bold text-slate-100 font-mono">{formatZAR(disbursedAmount)}</span>
<span className="text-slate-400">Disbursed</span>
</div>
{/* Timeline */}
<div className="flex items-center gap-1.5 font-medium text-slate-300">
<span className="p-1 rounded-md bg-cyan-500/10 text-cyan-400 flex items-center justify-center">
<Calendar className="w-3.5 h-3.5" />
</span>
<span className="font-medium text-slate-200">
{formatDate(project.startedAt || contract?.effectiveAt)} – {formatDate(project.endedAt || contract?.expiresAt)}
</span>
<span className="text-slate-400">({daysLeft})</span>
</div>
{/* Deliverables Count */}
<div className="flex items-center gap-1.5 font-medium text-slate-300">
<span className="p-1 rounded-md bg-slate-800 text-slate-300 flex items-center justify-center">
<Layers className="w-3.5 h-3.5" />
</span>
<span className="font-semibold text-slate-100">{effectiveMilestones.length} Milestones</span>
<span className="text-slate-400">• {totalActivitiesCount} Activities</span>
</div>
{/* Docs Submitted */}
<div className="flex items-center gap-1.5 font-medium text-slate-300">
<span className="p-1 rounded-md bg-emerald-500/10 text-emerald-400 flex items-center justify-center">
<Paperclip className="w-3.5 h-3.5" />
</span>
<span className="font-bold text-slate-100 font-mono">{effectiveEvidence.length}</span>
<span className="text-slate-400">Uploaded Proofs</span>
</div>
</div>
{/* Evidence Completion Progress Pill */}
<div className="flex items-center gap-2">
<span className="text-[11px] text-slate-400">Fulfillment:</span>
<span className="text-xs font-bold text-emerald-400 font-mono">{progressPercent}%</span>
<div className="w-20 bg-slate-950 rounded-full h-1.5 overflow-hidden border border-white/10">
<div
className="bg-gradient-to-r from-teal-500 via-emerald-400 to-cyan-300 h-full rounded-full transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
</div>
</div>
{/* =========================================================================
INLINE EXPANDED SECTION: MILESTONES & ACTIVITIES (Landing Page Palette)
========================================================================= */}
{isExpanded && (
<div className="mt-6 pt-6 border-t border-white/10 space-y-6 animate-in fade-in duration-300">
{/* Section Sub-header & Guidance */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 bg-slate-950/80 p-4 rounded-xl border border-white/5">
<div>
<h4 className="text-sm font-bold text-slate-100 flex items-center gap-2">
<Layers className="w-4 h-4 text-emerald-400" />
<span>Project Milestones & Contractual Activities</span>
{isFetchingDetails && (
<RefreshCw className="w-3.5 h-3.5 text-emerald-400 animate-spin ml-2" />
)}
</h4>
<p className="text-[11px] text-slate-400 mt-0.5">
All milestones are enabled. Complete activities by uploading supporting evidence documents, then submit for review and approval by organisation staff.
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => refetchProjectData()}
disabled={isFetchingDetails}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-white/5 hover:bg-white/10 text-xs text-slate-300 hover:text-emerald-300 transition-colors cursor-pointer"
title="Refresh project deliverables & evidence"
>
<RefreshCw className={`w-3.5 h-3.5 ${isFetchingDetails ? 'animate-spin text-emerald-400' : ''}`} />
<span>Refresh</span>
</button>
<div className="flex items-center gap-1.5 text-[11px] text-teal-300 bg-teal-950/60 border border-teal-500/30 px-3 py-1 rounded-lg">
<Info className="w-3.5 h-3.5" />
<span>Fulfillment Only</span>
</div>
</div>
</div>
{/* LOADING STATE WHEN VIEW DETAILS IS FIRST CLICKED */}
{isLoadingDetails ? (
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 flex flex-col items-center justify-center space-y-3 text-xs text-slate-400">
<Loader2 className="w-6 h-6 text-emerald-400 animate-spin" />
<span>Fetching project milestones, activities, and evidence...</span>
</div>
) : sortedMilestones.length === 0 ? (
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
<Layers className="w-8 h-8 text-slate-600 mx-auto" />
<p>No contractual milestones recorded for this project yet.</p>
<button
type="button"
onClick={() => refetchProjectData()}
className="inline-flex items-center gap-1 text-emerald-400 hover:underline pt-1 cursor-pointer"
>
<RefreshCw className="w-3 h-3" />
<span>Click here to re-check schedule</span>
</button>
</div>
) : (
/* MILESTONES LISTING (Sorted by Start Date then Due Date) */
<div className="space-y-5">
{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 (
<div
key={milestone.id}
className="rounded-2xl border border-white/[0.08] bg-slate-950/90 p-5 space-y-4 shadow-sm"
>
{/* Milestone Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-white/5">
<div className="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/70 border border-emerald-500/30 px-2.5 py-0.5 rounded-md">
Milestone #{mIdx + 1}
</span>
<span className="font-mono text-[10px] text-slate-400">
(ID: #{encodeHashIdSync(milestone.id)})
</span>
{/* Requirement #3: Decode hashId to show actual milestone ID */}
<span className="font-mono text-[10px] font-semibold text-slate-300 bg-slate-900 border border-white/10 px-2 py-0.5 rounded-md">
Actual ID: #{actualMilestoneId}
</span>
{/* Requirement #5: Milestone late and at risk badges */}
{isMilestoneOverdue ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-rose-950/90 border border-rose-500/50 text-rose-300 shadow-sm">
<AlertTriangle className="w-3 h-3 text-rose-400" />
<span>Late</span>
</span>
) : isMilestoneAtRisk ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-950/90 border border-amber-500/50 text-amber-300 shadow-sm">
<Clock className="w-3 h-3 text-amber-400" />
<span>At Risk (Due Soon)</span>
</span>
) : (
renderStatusBadge(milestone.status, 'In Progress')
)}
{isMilestoneFullyFulfilled && (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
<ShieldCheck className="w-3 h-3 text-emerald-400" />
<span>Milestone Fulfilled</span>
</span>
)}
{/* Requirement #6: Milestone level Rules & Risks trigger */}
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setGovernanceTarget({ project, milestone, initialTab: 'rules' });
}}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-white/[0.04] hover:bg-white/[0.08] text-[10px] font-medium text-slate-400 hover:text-white transition-colors cursor-pointer border border-white/5"
title="View Milestone Rules and Risk Guidance"
>
<Scale className="w-3 h-3 text-teal-400" />
<span>Rules & Risks</span>
</button>
</div>
<h5 className="text-base font-bold text-slate-100 mt-1">
{milestone.name || `Milestone Target #${mIdx + 1}`}
</h5>
</div>
<div className="flex items-center gap-4 text-xs text-slate-400">
<div>
<span className="text-[10px] text-slate-500 block uppercase font-bold">Start Date</span>
<span className="text-slate-300 font-mono">{formatDate(milestone.createdAt)}</span>
</div>
<div>
<span className="text-[10px] text-slate-500 block uppercase font-bold">Due Date</span>
<span className="text-emerald-300 font-bold font-mono">{formatDate(milestone.dueAt)}</span>
</div>
<div className="font-mono text-[11px] text-emerald-300 bg-emerald-950/60 border border-emerald-500/20 px-2.5 py-1 rounded-lg">
{milestoneFulfilledActs.length} / {milestoneActs.length} Fulfilled
</div>
</div>
</div>
{/* NESTED ACTIVITIES LISTING */}
<div className="space-y-3 pt-1">
<div className="text-[11px] font-semibold uppercase tracking-wider text-slate-400 flex items-center justify-between">
<span>Activities Under Milestone ({milestoneActs.length})</span>
<span className="text-[10px] font-normal normal-case text-slate-500">
Sorted by execution date
</span>
</div>
{milestoneActs.length === 0 ? (
<div className="p-4 rounded-xl bg-slate-900/40 border border-white/5 text-xs text-slate-500 italic">
No specific activities defined under this milestone.
</div>
) : (
<div className="space-y-3">
{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 (
<div
key={act.id}
className="p-4 rounded-xl bg-slate-900/40 border border-white/5 hover:border-emerald-500/20 transition-all space-y-3"
>
{/* Activity Details Row */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="space-y-1.5 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-slate-100">
{act.title || `Activity #${encodeHashIdSync(act.id)}`}
</span>
{act.category && (
<span className="text-[10px] font-medium px-2 py-0.5 rounded-md bg-emerald-500/10 border border-emerald-500/20 text-emerald-300">
{act.category}
</span>
)}
{isApproved ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-bold bg-emerald-950/80 border border-emerald-500/30 text-emerald-300">
<Check className="w-3 h-3 text-emerald-400" />
Approved by Staff
</span>
) : isUnderReview ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-bold bg-amber-950/80 border border-amber-500/30 text-amber-300">
<Clock className="w-3 h-3 text-amber-400" />
Submitted • Staff Review Pending
</span>
) : hasEvidence ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-bold bg-teal-950/80 border border-teal-500/30 text-teal-300">
<CheckCircle2 className="w-3 h-3 text-teal-400" />
{actEvidences.length} Proof{actEvidences.length > 1 ? 's' : ''} Attached (Ready)
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium bg-slate-950 border border-white/10 text-slate-400">
<Clock className="w-3 h-3 text-slate-500" />
Evidence Required
</span>
)}
</div>
{act.description && (
<p className="text-xs text-slate-300 leading-relaxed">
{act.description}
</p>
)}
<div className="flex items-center gap-4 text-[11px] text-slate-400 pt-0.5">
<span>
Budget: <span className="text-slate-200 font-mono font-bold">{formatZAR(act.amount)}</span>
</span>
<span>
Target Date: <span className="text-slate-300 font-mono">{formatDate(act.performedAt || act.createdAt)}</span>
</span>
</div>
</div>
{/* Action Buttons: Add Evidence + Submit for Approval */}
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => setUploadTarget({ project, milestone, activity: act })}
className="inline-flex items-center gap-1.5 px-3 py-1.5 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_12px_rgba(16,185,129,0.2)] active:scale-95"
title="Add supporting evidence document for this activity"
>
<Plus className="w-3.5 h-3.5" />
<span>{hasEvidence ? 'Add More Evidence' : 'Add Evidence'}</span>
</button>
{!isApproved && !isUnderReview && (
<button
type="button"
onClick={() => handleSubmitActivity(act, milestone)}
disabled={!hasEvidence || submittingActivityId === act.id}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-gradient-to-r from-emerald-500 via-teal-400 to-cyan-400 hover:from-emerald-400 hover:to-cyan-300 text-slate-950 text-xs font-bold transition-all shadow-[0_2px_12px_rgba(16,185,129,0.35)] hover:shadow-[0_4px_16px_rgba(16,185,129,0.45)] disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer active:scale-95"
title={hasEvidence ? 'Submit uploaded evidence for organisation staff approval' : 'Upload at least one document before submitting for approval'}
>
{submittingActivityId === act.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Send className="w-3.5 h-3.5" />
)}
<span>Submit for Approval</span>
</button>
)}
</div>
</div>
{/* LIST EXISTING EVIDENCE UNDER MATCHING ACTIVITY */}
{actEvidences.length > 0 && (
<div className="pt-2 border-t border-white/5 space-y-2">
<div className="text-[10px] uppercase font-bold tracking-wider text-slate-400 flex items-center justify-between">
<span>Attached Proof Documents ({actEvidences.length}):</span>
<span className="text-[10px] normal-case text-emerald-400">
Sorted by upload time
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{actEvidences.map((ev) => (
<div
key={ev.id}
className="flex items-center justify-between p-2.5 rounded-lg bg-slate-950 border border-white/10 text-xs"
>
<div className="flex items-center gap-2.5 min-w-0 pr-2">
<FileText className="w-4 h-4 text-teal-400 shrink-0" />
<div className="min-w-0">
<p className="text-slate-200 font-medium truncate">
{(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}
</p>
<p className="text-[10px] text-slate-400 truncate">
<span className="font-semibold text-emerald-300">
{getEvidenceTypeLabel(ev.type)}
</span>{' '}
• {formatDateTime(ev.createdAt)}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{renderStatusBadge(ev.status, 'Pending Review')}
<button
type="button"
onClick={() => onOpenSecureFile('evidence', ev.id)}
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-emerald-300 hover:text-emerald-200 transition-colors cursor-pointer"
title="View or download secure file"
>
<ExternalLink className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
})}
</div>
)}
{/* UNASSIGNED ACTIVITIES */}
{unassignedActivities.length > 0 && (
<div className="rounded-2xl border border-white/[0.08] bg-slate-950/90 p-5 space-y-4 shadow-sm">
<div className="border-b border-white/5 pb-2">
<h5 className="text-base font-bold text-slate-100 flex items-center gap-2">
<Layers className="w-4 h-4 text-cyan-400" />
<span>General Project Activities</span>
</h5>
<p className="text-xs text-slate-400 mt-0.5">
Activities not bound to a specific milestone. Fulfill by submitting required documents.
</p>
</div>
<div className="space-y-3">
{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 (
<div
key={act.id}
className="p-4 rounded-xl bg-slate-900/40 border border-white/5 hover:border-emerald-500/20 transition-all space-y-3"
>
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="space-y-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-slate-100">
{act.title || `Activity #${encodeHashIdSync(act.id)}`}
</span>
{renderStatusBadge(act.status, 'Pending Evidence')}
</div>
{act.description && (
<p className="text-xs text-slate-300 leading-relaxed">
{act.description}
</p>
)}
<div className="flex items-center gap-4 text-[11px] text-slate-400">
<span>Budget: <span className="text-slate-200 font-mono font-bold">{formatZAR(act.amount)}</span></span>
<span>Target Date: <span className="text-slate-300 font-mono">{formatDate(act.performedAt || act.createdAt)}</span></span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => setUploadTarget({ project, activity: act })}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-emerald-500/15 hover:bg-emerald-500/25 border border-emerald-500/30 text-emerald-300 text-xs font-semibold cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
<span>{hasEvidence ? 'Add More Evidence' : 'Add Evidence'}</span>
</button>
{!isApproved && !isUnderReview && (
<button
type="button"
onClick={() => handleSubmitActivity(act, null)}
disabled={!hasEvidence || submittingActivityId === act.id}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-gradient-to-r from-emerald-500 via-teal-400 to-cyan-400 hover:from-emerald-400 hover:to-cyan-300 text-slate-950 text-xs font-bold disabled:opacity-40 cursor-pointer"
>
<Send className="w-3.5 h-3.5" />
<span>Submit for Approval</span>
</button>
)}
</div>
</div>
{actEvidences.length > 0 && (
<div className="pt-2 border-t border-white/5 grid grid-cols-1 md:grid-cols-2 gap-2">
{actEvidences.map((ev) => (
<div
key={ev.id}
className="flex items-center justify-between p-2.5 rounded-lg bg-slate-950 border border-white/10 text-xs"
>
<div className="min-w-0 pr-2">
<span className="text-slate-200 truncate block">
{(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}
</span>
<span className="text-[10px] text-slate-400">
{getEvidenceTypeLabel(ev.type)} • {formatDateTime(ev.createdAt)}
</span>
</div>
<button
type="button"
onClick={() => onOpenSecureFile('evidence', ev.id)}
className="p-1 rounded text-emerald-300 hover:text-emerald-200"
>
<ExternalLink className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
)}
{/* EVIDENCE UPLOAD MODAL */}
{uploadTarget && (
<EvidenceUploadModal
isOpen={true}
onClose={() => setUploadTarget(null)}
project={uploadTarget.project}
milestone={uploadTarget.milestone}
activity={uploadTarget.activity}
onEvidenceUploaded={async () => {
await refetchProjectData();
setUploadTarget(null);
}}
/>
)}
{/* COMPLIANCE & RISKS INFORMATIONAL MODAL (Requirement #6) */}
{governanceTarget && (
<GovernanceInfoModal
isOpen={true}
onClose={() => setGovernanceTarget(null)}
project={governanceTarget.project}
milestone={governanceTarget.milestone}
activity={governanceTarget.activity}
initialTab={governanceTarget.initialTab}
/>
)}
</div>
);
};