955 lines
49 KiB
TypeScript
955 lines
49 KiB
TypeScript
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>
|
||
);
|
||
};
|