import React, { useMemo, useRef, useState, useEffect } from 'react'; import { Flag, Calendar, Clock, Briefcase, CheckCircle2, AlertCircle, Plus, Coins, ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Compass, ArrowRight, Sparkles, } from 'lucide-react'; import { Project, Milestone, Activity, ApprovalStatus, Priority } from '../../types'; import { formatDate, formatZAR, getApprovalStatusBadge, getPriorityBadge } from '../../lib/formatters'; interface ProjectTimelineGanttProps { project: Project; milestones: Milestone[]; activities: Activity[]; approvedBudgetTotal?: number; approvedBudgetsCount?: number; onManageMilestones: () => void; onManageActivities: () => void; } export const ProjectTimelineGantt: React.FC = ({ project, milestones, activities, approvedBudgetTotal = 0, approvedBudgetsCount = 0, onManageMilestones, onManageActivities, }) => { const scrollContainerRef = useRef(null); const [zoomLevel, setZoomLevel] = useState<'compact' | 'standard' | 'expanded'>('standard'); const [canScrollLeft, setCanScrollLeft] = useState(false); const [canScrollRight, setCanScrollRight] = useState(false); // Compute timeline boundaries, first/last milestones, and ruler ticks const timelineData = useMemo(() => { const dates: number[] = []; if (project.startedAt) dates.push(new Date(project.startedAt).getTime()); if (project.endedAt) dates.push(new Date(project.endedAt).getTime()); milestones.forEach((m) => { if (m.dueAt) dates.push(new Date(m.dueAt).getTime()); }); activities.forEach((a) => { if (a.performedAt) dates.push(new Date(a.performedAt).getTime()); }); const now = Date.now(); dates.push(now); let startMs: number; let endMs: number; if (dates.length > 0 && Math.min(...dates) !== Math.max(...dates)) { startMs = Math.min(...dates); endMs = Math.max(...dates); // Aesthetic buffer (6% on each side, minimum 10 days) const buffer = Math.max((endMs - startMs) * 0.06, 10 * 24 * 60 * 60 * 1000); startMs -= buffer; endMs += buffer; } else { // Default fallback startMs = now - 60 * 24 * 60 * 60 * 1000; endMs = now + 240 * 24 * 60 * 60 * 1000; } const totalDurationMs = endMs - startMs; // Helper to calculate percentage offset along timeline const getPercent = (timeMs: number): number => { const clamped = Math.max(startMs, Math.min(endMs, timeMs)); return ((clamped - startMs) / totalDurationMs) * 100; }; // Monthly ruler ticks const ticks: { label: string; yearLabel?: string; percent: number; isStartOfYear: boolean; isQuarter: boolean }[] = []; const currDate = new Date(startMs); currDate.setDate(1); currDate.setHours(0, 0, 0, 0); while (currDate.getTime() <= endMs) { if (currDate.getTime() >= startMs) { const monthNum = currDate.getMonth(); ticks.push({ label: currDate.toLocaleDateString('en-GB', { month: 'short' }), yearLabel: currDate.getFullYear().toString(), percent: getPercent(currDate.getTime()), isStartOfYear: monthNum === 0, isQuarter: monthNum % 3 === 0, }); } currDate.setMonth(currDate.getMonth() + 1); } // Sort milestones chronologically const sortedMilestones = [...milestones].sort( (a, b) => new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime() ); // Identify first and last milestones const firstMilestone = sortedMilestones.length > 0 ? sortedMilestones[0] : null; const lastMilestone = sortedMilestones.length > 0 ? sortedMilestones[sortedMilestones.length - 1] : null; // Sort activities chronologically const sortedActivities = [...activities].sort( (a, b) => new Date(a.performedAt || 0).getTime() - new Date(b.performedAt || 0).getTime() ); const todayPercent = getPercent(now); const isTodayInRange = todayPercent >= 0 && todayPercent <= 100; const projectStartPercent = project.startedAt ? getPercent(new Date(project.startedAt).getTime()) : null; const projectEndPercent = project.endedAt ? getPercent(new Date(project.endedAt).getTime()) : null; return { startMs, endMs, totalDurationDays: Math.round(totalDurationMs / (1000 * 60 * 60 * 24)), getPercent, ticks, sortedMilestones, firstMilestone, lastMilestone, sortedActivities, todayPercent, isTodayInRange, projectStartPercent, projectEndPercent, }; }, [project, milestones, activities]); // Check scroll capability const updateScrollState = () => { const el = scrollContainerRef.current; if (!el) return; setCanScrollLeft(el.scrollLeft > 10); setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 10); }; useEffect(() => { updateScrollState(); const el = scrollContainerRef.current; if (el) { el.addEventListener('scroll', updateScrollState); window.addEventListener('resize', updateScrollState); } return () => { if (el) el.removeEventListener('scroll', updateScrollState); window.removeEventListener('resize', updateScrollState); }; }, [timelineData, zoomLevel]); // Horizontal shift handlers (requirement 4) const handleScrollLeft = () => { if (!scrollContainerRef.current) return; scrollContainerRef.current.scrollBy({ left: -360, behavior: 'smooth' }); }; const handleScrollRight = () => { if (!scrollContainerRef.current) return; scrollContainerRef.current.scrollBy({ left: 360, behavior: 'smooth' }); }; // Quick jump handlers (requirement 5) const scrollToPercent = (percent: number) => { const el = scrollContainerRef.current; if (!el) return; const totalWidth = el.scrollWidth; const clientWidth = el.clientWidth; const targetScrollLeft = (percent / 100) * totalWidth - clientWidth / 2; el.scrollTo({ left: Math.max(0, targetScrollLeft), behavior: 'smooth' }); }; const scrollToMilestone = (m: Milestone) => { if (!m.dueAt) return; const pct = timelineData.getPercent(new Date(m.dueAt).getTime()); scrollToPercent(pct); }; const scrollToToday = () => { if (timelineData.isTodayInRange) { scrollToPercent(timelineData.todayPercent); } }; const scrollToProjectStart = () => { if (timelineData.projectStartPercent !== null) { scrollToPercent(timelineData.projectStartPercent); } else if (timelineData.firstMilestone) { scrollToMilestone(timelineData.firstMilestone); } }; const scrollToProjectEnd = () => { if (timelineData.projectEndPercent !== null) { scrollToPercent(timelineData.projectEndPercent); } else if (timelineData.lastMilestone) { scrollToMilestone(timelineData.lastMilestone); } }; // Zoom width classes const canvasWidthClass = zoomLevel === 'compact' ? 'min-w-[1200px]' : zoomLevel === 'standard' ? 'min-w-[1900px]' : 'min-w-[2800px]'; const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length; const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0; const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0); return (
{/* Timeline Header, Key Stats & Enhanced Navigation Bar */}
{/* Title & Span Info */}

Implementation Schedule & Timeline Gantt

{timelineData.totalDurationDays} Days Span {project.startedAt && project.endedAt && ( ({formatDate(project.startedAt)} → {formatDate(project.endedAt)}) )}

Chronological alignment of contractual deliverables, work breakdown activities, and target milestones.

{/* Enhanced Navigation Toolbar (Quick Jump, Zoom, Scroll Controls) */}
{/* Quick Jump Buttons (Requirement 5) */}
Jump: {/* Jump to First Milestone */} {timelineData.firstMilestone ? ( ) : project.startedAt ? ( ) : null} {/* Jump to Today */} {timelineData.isTodayInRange && ( )} {/* Jump to Last Milestone */} {timelineData.lastMilestone ? ( ) : project.endedAt ? ( ) : null}
{/* Zoom Level Selector */}
{/* Scroll Shift Buttons in Top Header */}
{/* Milestone & Activity Legend & Milestone Span Pill */}
Verified Milestone Pending Review Contractual Deliverable Work Activity
{/* First & Last Milestone Span Pill (Requirement 5) */} {timelineData.firstMilestone && timelineData.lastMilestone && (
1st: {formatDate(timelineData.firstMilestone.dueAt)} Final: {formatDate(timelineData.lastMilestone.dueAt)}
)}
{/* Scrollable Timeline Visualization Container */}
{/* Scrollable Viewport */}
{/* Inner Canvas with expansive width */}
{/* Background vertical month grid lines */}
{timelineData.ticks.map((tick, idx) => (
))} {/* Project Start Line Marker */} {timelineData.projectStartPercent !== null && (
Start: {formatDate(project.startedAt)}
)} {/* Project Target End Line Marker */} {timelineData.projectEndPercent !== null && (
End: {formatDate(project.endedAt)}
)} {/* Today line marker */} {timelineData.isTodayInRange && (
Today
)}
{/* Month Scale / Ruler */}
{timelineData.ticks.map((tick, idx) => (
{tick.label} {tick.isStartOfYear && ( {tick.yearLabel} )}
))}
{/* Layer 1: Contractual Deliverables & Milestones Track */}
Contractual Deliverables & Milestones ({milestones.length})
{timelineData.sortedMilestones.length === 0 ? (
No contractual milestones defined yet. Click "Add / Manage Milestones" to establish deliverables.
) : (
{/* Base connecting line */}
{/* Visual progress overlay */}
{/* Milestone Pins */} {timelineData.sortedMilestones.map((m, idx) => { const mTime = new Date(m.dueAt || 0).getTime(); const pos = timelineData.getPercent(mTime); const isApproved = m.status === ApprovalStatus.Approved; const isPending = m.status === ApprovalStatus.Pending; const isFirst = timelineData.firstMilestone?.id === m.id; const isLast = timelineData.lastMilestone?.id === m.id && timelineData.sortedMilestones.length > 1; const priBadge = getPriorityBadge(m.priority); return (
{/* First / Last Milestone Distinctive Badge Indicator (Requirement 5) */} {isFirst && (
1st Milestone
)} {isLast && !isFirst && (
Final Close-out
)} {/* Milestone Flag Icon Bubble */}
{/* Deliverable Label */}
{m.name} {formatDate(m.dueAt)}
{/* Rich Hover Card */}
Milestone #{m.id} {priBadge.label}

{m.name}

{formatDate(m.dueAt)} {isApproved ? '✓ Verified' : '● Pending Review'}
); })}
)}
{/* Layer 2: Work Breakdown Activities Track */}
Work Breakdown Activities ({activities.length})
{timelineData.sortedActivities.length === 0 ? (
No activities recorded in work breakdown structure.
) : (
{timelineData.sortedActivities.slice(0, 5).map((act, idx) => { const aTime = new Date(act.performedAt || project.startedAt || Date.now()).getTime(); const pos = timelineData.getPercent(aTime); return (
{act.title} {act.category || 'Operations'}
{formatZAR(act.amount)}
{formatDate(act.performedAt)}
); })} {activities.length > 5 && (
)}
)}
{/* BOTTOM NAVIGATION ARROWS (EXACTLY WHERE RED ARROWS WERE IN USER'S SCREENSHOT) */} {/* Bottom Left Navigation Arrow Button */}
{/* Bottom Right Navigation Arrow Button */}
); };