Refactored project view into modal
Elevated toast message priority
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import {
|
||||
Flag,
|
||||
Calendar,
|
||||
@@ -6,9 +6,15 @@ import {
|
||||
Briefcase,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
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';
|
||||
@@ -32,7 +38,12 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
onManageMilestones,
|
||||
onManageActivities,
|
||||
}) => {
|
||||
// Compute timeline boundaries and items
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(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[] = [];
|
||||
|
||||
@@ -56,14 +67,14 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
if (dates.length > 0 && Math.min(...dates) !== Math.max(...dates)) {
|
||||
startMs = Math.min(...dates);
|
||||
endMs = Math.max(...dates);
|
||||
// Add a slight buffer (5% on each side) for aesthetic padding
|
||||
const buffer = Math.max((endMs - startMs) * 0.05, 7 * 24 * 60 * 60 * 1000);
|
||||
// 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: current quarter +/- 3 months
|
||||
startMs = now - 90 * 24 * 60 * 60 * 1000;
|
||||
endMs = now + 180 * 24 * 60 * 60 * 1000;
|
||||
// Default fallback
|
||||
startMs = now - 60 * 24 * 60 * 60 * 1000;
|
||||
endMs = now + 240 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
const totalDurationMs = endMs - startMs;
|
||||
@@ -74,28 +85,36 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
return ((clamped - startMs) / totalDurationMs) * 100;
|
||||
};
|
||||
|
||||
// Generate monthly ticks for ruler
|
||||
const ticks: { label: string; percent: number; isStartOfYear: boolean }[] = [];
|
||||
// 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', year: '2-digit' }),
|
||||
label: currDate.toLocaleDateString('en-GB', { month: 'short' }),
|
||||
yearLabel: currDate.getFullYear().toString(),
|
||||
percent: getPercent(currDate.getTime()),
|
||||
isStartOfYear: currDate.getMonth() === 0,
|
||||
isStartOfYear: monthNum === 0,
|
||||
isQuarter: monthNum % 3 === 0,
|
||||
});
|
||||
}
|
||||
currDate.setMonth(currDate.getMonth() + 1);
|
||||
}
|
||||
|
||||
// Sort milestones & activities by date
|
||||
// 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()
|
||||
);
|
||||
@@ -103,6 +122,9 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
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,
|
||||
@@ -110,321 +132,608 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
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 (
|
||||
<div className="bg-[#0b101c] rounded-2xl border border-slate-800/80 p-5 space-y-5 overflow-hidden shadow-inner">
|
||||
{/* Timeline Header & Key Stats */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-4 border-b border-slate-800/80">
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<div className="h-8 w-8 rounded-xl bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-400 shrink-0">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<div className="bg-[#0b101c] rounded-2xl border border-slate-800/80 p-4 sm:p-5 space-y-4 shadow-xl w-full min-w-0 relative">
|
||||
{/* Timeline Header, Key Stats & Enhanced Navigation Bar */}
|
||||
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-3.5 pb-4 border-b border-slate-800/80 min-w-0">
|
||||
{/* Title & Span Info */}
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="h-10 w-10 rounded-2xl bg-gradient-to-br from-blue-600/20 to-indigo-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-md shadow-blue-500/10 shrink-0">
|
||||
<Calendar className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2 flex-wrap">
|
||||
Implementation Schedule & Timeline
|
||||
<span className="text-[10px] normal-case font-normal text-slate-400">
|
||||
({timelineData.totalDurationDays} Days Span)
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-bold text-white tracking-wide uppercase">
|
||||
Implementation Schedule & Timeline Gantt
|
||||
</h4>
|
||||
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-full border border-blue-500/30">
|
||||
{timelineData.totalDurationDays} Days Span
|
||||
</span>
|
||||
</h4>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Chronological alignment of contractual deliverables and operational activities.
|
||||
{project.startedAt && project.endedAt && (
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
({formatDate(project.startedAt)} → {formatDate(project.endedAt)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5 truncate">
|
||||
Chronological alignment of contractual deliverables, work breakdown activities, and target milestones.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Legend & Actions */}
|
||||
<div className="flex items-center gap-2 text-[11px] flex-wrap shrink-0">
|
||||
<div className="flex items-center gap-3 px-3 py-1 bg-slate-900/80 rounded-xl border border-slate-800 text-slate-400 flex-wrap">
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block shadow-sm shadow-emerald-500/50" />
|
||||
Verified Deliverable
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 inline-block shadow-sm shadow-amber-500/50" />
|
||||
Pending / Review
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap">
|
||||
<span className="w-2.5 h-2.5 rounded-sm bg-blue-500 inline-block shadow-sm shadow-blue-500/50" />
|
||||
Work Activity
|
||||
</span>
|
||||
{/* Enhanced Navigation Toolbar (Quick Jump, Zoom, Scroll Controls) */}
|
||||
<div className="flex items-center gap-2 flex-wrap shrink-0">
|
||||
{/* Quick Jump Buttons (Requirement 5) */}
|
||||
<div className="flex items-center gap-1.5 p-1 bg-slate-900/90 rounded-xl border border-slate-800 text-xs flex-wrap">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 px-1.5 hidden sm:inline">Jump:</span>
|
||||
|
||||
{/* Jump to First Milestone */}
|
||||
{timelineData.firstMilestone ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToMilestone(timelineData.firstMilestone!)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-300 border border-emerald-500/30 transition cursor-pointer shadow-xs"
|
||||
title={`Jump to First Milestone: "${timelineData.firstMilestone.name}" (${formatDate(timelineData.firstMilestone.dueAt)})`}
|
||||
>
|
||||
<Flag className="w-3 h-3 text-emerald-400" />
|
||||
<span className="max-w-[120px] truncate">
|
||||
Start: {timelineData.firstMilestone.name}
|
||||
</span>
|
||||
</button>
|
||||
) : project.startedAt ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToProjectStart}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition cursor-pointer"
|
||||
title={`Jump to Project Start (${formatDate(project.startedAt)})`}
|
||||
>
|
||||
<Calendar className="w-3 h-3 text-blue-400" />
|
||||
<span>Start Date</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* Jump to Today */}
|
||||
{timelineData.isTodayInRange && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToToday}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-amber-500/15 hover:bg-amber-500/25 text-amber-300 border border-amber-500/40 transition cursor-pointer shadow-xs"
|
||||
title="Jump directly to current date on timeline"
|
||||
>
|
||||
<Clock className="w-3 h-3 text-amber-400" />
|
||||
<span>Today</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Jump to Last Milestone */}
|
||||
{timelineData.lastMilestone ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToMilestone(timelineData.lastMilestone!)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 border border-indigo-500/30 transition cursor-pointer shadow-xs"
|
||||
title={`Jump to Final Milestone: "${timelineData.lastMilestone.name}" (${formatDate(timelineData.lastMilestone.dueAt)})`}
|
||||
>
|
||||
<CheckCircle2 className="w-3 h-3 text-indigo-400" />
|
||||
<span className="max-w-[120px] truncate">
|
||||
Final: {timelineData.lastMilestone.name}
|
||||
</span>
|
||||
</button>
|
||||
) : project.endedAt ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToProjectEnd}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition cursor-pointer"
|
||||
title={`Jump to Project Target End (${formatDate(project.endedAt)})`}
|
||||
>
|
||||
<Calendar className="w-3 h-3 text-indigo-400" />
|
||||
<span>Target End</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Zoom Level Selector */}
|
||||
<div className="flex items-center p-0.5 bg-slate-900/90 rounded-xl border border-slate-800 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomLevel('compact')}
|
||||
className={`px-2 py-1 rounded-lg font-medium transition cursor-pointer ${
|
||||
zoomLevel === 'compact'
|
||||
? 'bg-blue-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="Compact View (Fits broader duration)"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomLevel('standard')}
|
||||
className={`px-2 py-1 rounded-lg font-medium transition cursor-pointer ${
|
||||
zoomLevel === 'standard'
|
||||
? 'bg-blue-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="Standard View (Balanced spacing)"
|
||||
>
|
||||
Standard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomLevel('expanded')}
|
||||
className={`px-2 py-1 rounded-lg font-medium transition cursor-pointer ${
|
||||
zoomLevel === 'expanded'
|
||||
? 'bg-blue-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="Expanded View (Deep detail, optimal for scrolling)"
|
||||
>
|
||||
Expanded
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scroll Shift Buttons in Top Header */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScrollLeft}
|
||||
disabled={!canScrollLeft}
|
||||
className="p-1.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-300 hover:text-white border border-slate-700/80 transition cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Shift timeline left (360px)"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScrollRight}
|
||||
disabled={!canScrollRight}
|
||||
className="p-1.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-300 hover:text-white border border-slate-700/80 transition cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Shift timeline right (360px)"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Gantt Timeline Track Container */}
|
||||
<div className="space-y-4 pt-1">
|
||||
{/* Month Scale / Ruler */}
|
||||
<div className="relative h-6 w-full border-b border-slate-800 text-[10px] font-mono text-slate-500 select-none">
|
||||
{timelineData.ticks.map((tick, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="absolute transform -translate-x-1/2 flex flex-col items-center"
|
||||
style={{ left: `${tick.percent}%` }}
|
||||
>
|
||||
<span className={tick.isStartOfYear ? 'text-blue-400 font-bold' : 'text-slate-400'}>
|
||||
{tick.label}
|
||||
</span>
|
||||
<span className="h-1.5 w-px bg-slate-700 mt-0.5" />
|
||||
</div>
|
||||
))}
|
||||
{/* Milestone & Activity Legend & Milestone Span Pill */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2.5 text-xs text-slate-400 flex-wrap">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 shadow-sm shadow-emerald-500/50 inline-block" />
|
||||
<strong className="text-slate-300">Verified Milestone</strong>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 shadow-sm shadow-amber-500/50 inline-block" />
|
||||
<strong className="text-slate-300">Pending Review</strong>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-blue-500 shadow-sm shadow-blue-500/50 inline-block" />
|
||||
<strong className="text-slate-300">Contractual Deliverable</strong>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-sm bg-cyan-500 shadow-sm shadow-cyan-500/50 inline-block" />
|
||||
<strong className="text-slate-300">Work Activity</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timeline Visualization Surface */}
|
||||
<div className="relative min-h-[290px] bg-[#090d16] rounded-xl border border-slate-800/90 pt-8 pb-5 px-4 overflow-x-auto">
|
||||
{/* Background vertical grid lines */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{timelineData.ticks.map((tick, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="absolute top-0 bottom-0 w-px bg-slate-800/40"
|
||||
style={{ left: `${tick.percent}%` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Today marker */}
|
||||
{timelineData.isTodayInRange && (
|
||||
<div
|
||||
className="absolute top-1 bottom-0 z-20 flex flex-col items-center pointer-events-none"
|
||||
style={{ left: `${timelineData.todayPercent}%` }}
|
||||
>
|
||||
<span className="px-2 py-0.5 text-[9px] font-bold text-amber-300 bg-amber-500/25 border border-amber-500/50 rounded-full whitespace-nowrap shadow-md shadow-amber-500/20">
|
||||
Today
|
||||
</span>
|
||||
<div className="w-px h-full bg-amber-400/80 border-dashed border-l border-amber-400 shadow-sm shadow-amber-400/30 mt-1" />
|
||||
</div>
|
||||
)}
|
||||
{/* First & Last Milestone Span Pill (Requirement 5) */}
|
||||
{timelineData.firstMilestone && timelineData.lastMilestone && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-mono bg-slate-900/90 px-3 py-1 rounded-full border border-slate-800 text-slate-300">
|
||||
<span className="text-emerald-400 font-semibold">1st: {formatDate(timelineData.firstMilestone.dueAt)}</span>
|
||||
<ArrowRight className="w-3 h-3 text-slate-500" />
|
||||
<span className="text-indigo-400 font-semibold">Final: {formatDate(timelineData.lastMilestone.dueAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Layer 1: Milestones Track */}
|
||||
<div className="relative z-10 mb-6 space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px] font-semibold text-slate-300 px-1 gap-2 flex-wrap">
|
||||
<span className="flex items-center gap-1.5 text-blue-400">
|
||||
<Flag className="w-3.5 h-3.5" />
|
||||
Contractual Deliverables & Milestones ({milestones.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageMilestones}
|
||||
className="text-[10px] text-blue-400 hover:text-blue-300 hover:underline cursor-pointer flex items-center gap-1 font-medium whitespace-nowrap shrink-0"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> Add / Manage Milestones
|
||||
</button>
|
||||
{/* Scrollable Timeline Visualization Container */}
|
||||
<div className="relative group/timeline rounded-2xl border border-slate-800/90 bg-[#070b13] overflow-hidden shadow-2xl">
|
||||
{/* Scrollable Viewport */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="overflow-x-auto scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-slate-900 scroll-smooth w-full select-none"
|
||||
>
|
||||
{/* Inner Canvas with expansive width */}
|
||||
<div className={`relative pt-7 pb-14 px-6 min-h-[340px] ${canvasWidthClass}`}>
|
||||
{/* Background vertical month grid lines */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{timelineData.ticks.map((tick, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`absolute top-0 bottom-0 w-px ${
|
||||
tick.isStartOfYear
|
||||
? 'bg-blue-500/30 border-dashed border-l border-blue-500/40'
|
||||
: tick.isQuarter
|
||||
? 'bg-slate-700/50'
|
||||
: 'bg-slate-800/40'
|
||||
}`}
|
||||
style={{ left: `${tick.percent}%` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Project Start Line Marker */}
|
||||
{timelineData.projectStartPercent !== null && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 z-10 flex flex-col items-center pointer-events-none"
|
||||
style={{ left: `${timelineData.projectStartPercent}%` }}
|
||||
>
|
||||
<span className="px-2 py-0.5 text-[9px] font-bold text-blue-300 bg-blue-600/30 border border-blue-500/50 rounded-full whitespace-nowrap shadow-md">
|
||||
Start: {formatDate(project.startedAt)}
|
||||
</span>
|
||||
<div className="w-px h-full bg-blue-500/50 border-dashed border-l border-blue-400 mt-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Target End Line Marker */}
|
||||
{timelineData.projectEndPercent !== null && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 z-10 flex flex-col items-center pointer-events-none"
|
||||
style={{ left: `${timelineData.projectEndPercent}%` }}
|
||||
>
|
||||
<span className="px-2 py-0.5 text-[9px] font-bold text-indigo-300 bg-indigo-600/30 border border-indigo-500/50 rounded-full whitespace-nowrap shadow-md">
|
||||
End: {formatDate(project.endedAt)}
|
||||
</span>
|
||||
<div className="w-px h-full bg-indigo-500/50 border-dashed border-l border-indigo-400 mt-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Today line marker */}
|
||||
{timelineData.isTodayInRange && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 z-20 flex flex-col items-center pointer-events-none"
|
||||
style={{ left: `${timelineData.todayPercent}%` }}
|
||||
>
|
||||
<span className="px-2.5 py-0.5 text-[9px] font-bold text-amber-300 bg-amber-500/30 border border-amber-400/60 rounded-full whitespace-nowrap shadow-lg shadow-amber-500/25">
|
||||
Today
|
||||
</span>
|
||||
<div className="w-px h-full bg-amber-400 border-l border-amber-400 shadow-md shadow-amber-400/40 mt-1" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{timelineData.sortedMilestones.length === 0 ? (
|
||||
<div className="p-4 bg-slate-900/40 rounded-lg border border-dashed border-slate-800 text-center text-xs text-slate-500">
|
||||
No contractual milestones defined yet. Click "Add / Manage Milestones" to establish deliverables.
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-24 bg-slate-900/50 rounded-xl border border-slate-800/80 flex items-center px-3">
|
||||
{/* Milestone connecting base line */}
|
||||
<div className="absolute left-3 right-3 h-1 bg-slate-800 rounded-full" />
|
||||
|
||||
{/* 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 priBadge = getPriorityBadge(m.priority);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id || idx}
|
||||
className="absolute group z-10 -translate-x-1/2 cursor-pointer transition-all duration-200"
|
||||
style={{ left: `${Math.min(96, Math.max(4, pos))}%` }}
|
||||
onClick={onManageMilestones}
|
||||
{/* Month Scale / Ruler */}
|
||||
<div className="relative h-9 border-b border-slate-800 text-[11px] font-mono text-slate-400 mb-6">
|
||||
{timelineData.ticks.map((tick, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="absolute transform -translate-x-1/2 flex flex-col items-center"
|
||||
style={{ left: `${tick.percent}%` }}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={
|
||||
tick.isStartOfYear
|
||||
? 'text-blue-400 font-bold text-xs'
|
||||
: tick.isQuarter
|
||||
? 'text-slate-200 font-semibold'
|
||||
: 'text-slate-400'
|
||||
}
|
||||
>
|
||||
{/* Flag Marker */}
|
||||
{tick.label}
|
||||
</span>
|
||||
{tick.isStartOfYear && (
|
||||
<span className="text-[9px] text-blue-300 font-mono bg-blue-500/20 px-1 rounded">
|
||||
{tick.yearLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="h-2 w-px bg-slate-700 mt-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Layer 1: Contractual Deliverables & Milestones Track */}
|
||||
<div className="relative z-10 mb-8 space-y-2.5">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-slate-200 px-1 gap-2 flex-wrap">
|
||||
<span className="flex items-center gap-2 text-blue-400">
|
||||
<Flag className="w-4 h-4 text-blue-400" />
|
||||
Contractual Deliverables & Milestones ({milestones.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageMilestones}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 hover:underline cursor-pointer flex items-center gap-1 font-semibold whitespace-nowrap"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add / Manage Milestones
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{timelineData.sortedMilestones.length === 0 ? (
|
||||
<div className="p-6 bg-slate-900/40 rounded-xl border border-dashed border-slate-800 text-center text-xs text-slate-400">
|
||||
No contractual milestones defined yet. Click "Add / Manage Milestones" to establish deliverables.
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-28 bg-slate-900/60 rounded-2xl border border-slate-800/80 flex items-center px-4 shadow-inner">
|
||||
{/* Base connecting line */}
|
||||
<div className="absolute left-4 right-4 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
{/* Visual progress overlay */}
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-500 to-blue-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${milestoneProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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 (
|
||||
<div
|
||||
className={`h-8 w-8 rounded-full flex items-center justify-center shadow-lg transition-transform group-hover:scale-125 border ${
|
||||
isApproved
|
||||
? 'bg-emerald-500/20 border-emerald-400 text-emerald-300 shadow-emerald-500/20'
|
||||
: isPending
|
||||
? 'bg-amber-500/20 border-amber-400 text-amber-300 shadow-amber-500/20'
|
||||
: 'bg-blue-500/20 border-blue-400 text-blue-300 shadow-blue-500/20'
|
||||
}`}
|
||||
key={m.id || idx}
|
||||
className="absolute group z-10 -translate-x-1/2 cursor-pointer transition-all duration-200"
|
||||
style={{ left: `${Math.min(97, Math.max(3, pos))}%` }}
|
||||
onClick={onManageMilestones}
|
||||
>
|
||||
<Flag className="w-3.5 h-3.5 fill-current" />
|
||||
</div>
|
||||
{/* First / Last Milestone Distinctive Badge Indicator (Requirement 5) */}
|
||||
{isFirst && (
|
||||
<div className="absolute -top-7 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
||||
<span className="px-2 py-0.5 rounded-full text-[9px] font-bold bg-emerald-500/25 border border-emerald-400 text-emerald-300 shadow-md flex items-center gap-1">
|
||||
<Sparkles className="w-2.5 h-2.5 text-emerald-400" />
|
||||
1st Milestone
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isLast && !isFirst && (
|
||||
<div className="absolute -top-7 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
||||
<span className="px-2 py-0.5 rounded-full text-[9px] font-bold bg-indigo-500/25 border border-indigo-400 text-indigo-300 shadow-md flex items-center gap-1">
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-indigo-400" />
|
||||
Final Close-out
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Brief Pin Label */}
|
||||
<div className="mt-1 text-center whitespace-nowrap">
|
||||
<span className="text-[10px] font-bold text-slate-300 block max-w-[110px] truncate">
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-500 font-mono">
|
||||
{formatDate(m.dueAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tooltip on Hover */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:flex flex-col w-56 p-2.5 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl text-left z-30 pointer-events-none">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<span className="text-[10px] font-mono text-blue-400">Milestone #{m.id}</span>
|
||||
<span className={`text-[9px] px-1.5 py-0.2 rounded border ${priBadge.className}`}>
|
||||
{priBadge.label}
|
||||
</span>
|
||||
{/* Milestone Flag Icon Bubble */}
|
||||
<div
|
||||
className={`h-9 w-9 rounded-2xl flex items-center justify-center shadow-lg transition-transform group-hover:scale-125 border ${
|
||||
isApproved
|
||||
? 'bg-emerald-500/25 border-emerald-400 text-emerald-300 shadow-emerald-500/30'
|
||||
: isPending
|
||||
? 'bg-amber-500/25 border-amber-400 text-amber-300 shadow-amber-500/30'
|
||||
: 'bg-blue-500/25 border-blue-400 text-blue-300 shadow-blue-500/30'
|
||||
} ${isFirst ? 'ring-2 ring-emerald-400/50' : ''} ${isLast ? 'ring-2 ring-indigo-400/50' : ''}`}
|
||||
>
|
||||
<Flag className="w-4 h-4 fill-current" />
|
||||
</div>
|
||||
<p className="text-xs font-bold text-white leading-tight mb-1">{m.name}</p>
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-400 pt-1 border-t border-slate-800">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-slate-500" />
|
||||
|
||||
{/* Deliverable Label */}
|
||||
<div className="mt-1 text-center whitespace-nowrap">
|
||||
<span className="text-[11px] font-bold text-slate-200 block max-w-[130px] truncate group-hover:text-white">
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono block">
|
||||
{formatDate(m.dueAt)}
|
||||
</span>
|
||||
<span className={isApproved ? 'text-emerald-400 font-semibold' : 'text-amber-400'}>
|
||||
{isApproved ? 'Verified' : 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Rich Hover Card */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 hidden group-hover:flex flex-col w-64 p-3 bg-slate-900 border border-slate-700/90 rounded-2xl shadow-2xl text-left z-30 pointer-events-none backdrop-blur-md">
|
||||
<div className="flex items-center justify-between gap-1 mb-1.5">
|
||||
<span className="text-[10px] font-mono text-blue-400 font-semibold">
|
||||
Milestone #{m.id}
|
||||
</span>
|
||||
<span className={`text-[9px] px-2 py-0.5 rounded border ${priBadge.className}`}>
|
||||
{priBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-white leading-tight mb-2">{m.name}</p>
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400 pt-2 border-t border-slate-800">
|
||||
<span className="flex items-center gap-1 font-mono">
|
||||
<Clock className="w-3.5 h-3.5 text-slate-500" />
|
||||
{formatDate(m.dueAt)}
|
||||
</span>
|
||||
<span className={isApproved ? 'text-emerald-400 font-bold' : 'text-amber-400 font-semibold'}>
|
||||
{isApproved ? '✓ Verified' : '● Pending Review'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Layer 2: Activities Track */}
|
||||
<div className="relative z-10 space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px] font-semibold text-slate-300 px-1 gap-2 flex-wrap">
|
||||
<span className="flex items-center gap-1.5 text-cyan-400">
|
||||
<Briefcase className="w-3.5 h-3.5" />
|
||||
Work Breakdown Activities ({activities.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageActivities}
|
||||
className="text-[10px] text-cyan-400 hover:text-cyan-300 hover:underline cursor-pointer flex items-center gap-1 font-medium whitespace-nowrap shrink-0"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> Add / Manage Activities
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{timelineData.sortedActivities.length === 0 ? (
|
||||
<div className="p-3 bg-slate-900/40 rounded-lg border border-dashed border-slate-800 text-center text-xs text-slate-500">
|
||||
No activities recorded in work breakdown structure.
|
||||
{/* Layer 2: Work Breakdown Activities Track */}
|
||||
<div className="relative z-10 space-y-2.5">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-slate-200 px-1 gap-2 flex-wrap">
|
||||
<span className="flex items-center gap-2 text-cyan-400">
|
||||
<Briefcase className="w-4 h-4 text-cyan-400" />
|
||||
Work Breakdown Activities ({activities.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageActivities}
|
||||
className="text-xs text-cyan-400 hover:text-cyan-300 hover:underline cursor-pointer flex items-center gap-1 font-semibold whitespace-nowrap"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add / Manage Activities
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{timelineData.sortedActivities.slice(0, 4).map((act, idx) => {
|
||||
const aTime = new Date(act.performedAt || project.startedAt || Date.now()).getTime();
|
||||
const pos = timelineData.getPercent(aTime);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={act.id || idx}
|
||||
className="group relative flex items-center p-2 rounded-xl bg-slate-900/80 hover:bg-slate-800/90 border border-slate-800 hover:border-slate-700 transition cursor-pointer"
|
||||
onClick={onManageActivities}
|
||||
>
|
||||
<div className="w-48 shrink-0 pr-3 border-r border-slate-800 truncate">
|
||||
<span className="text-xs font-semibold text-white block truncate">{act.title}</span>
|
||||
<span className="text-[10px] text-slate-400">{act.category || 'Operations'}</span>
|
||||
</div>
|
||||
{timelineData.sortedActivities.length === 0 ? (
|
||||
<div className="p-4 bg-slate-900/40 rounded-xl border border-dashed border-slate-800 text-center text-xs text-slate-400">
|
||||
No activities recorded in work breakdown structure.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{timelineData.sortedActivities.slice(0, 5).map((act, idx) => {
|
||||
const aTime = new Date(act.performedAt || project.startedAt || Date.now()).getTime();
|
||||
const pos = timelineData.getPercent(aTime);
|
||||
|
||||
<div className="flex-1 relative h-6 mx-3 flex items-center">
|
||||
<div className="w-full h-1 bg-slate-800 rounded-full" />
|
||||
<div
|
||||
className="absolute h-4 rounded-md bg-cyan-500/20 border border-cyan-400/60 px-2 flex items-center text-[9px] font-bold text-cyan-300 shadow-sm shadow-cyan-500/20 -translate-x-1/2 whitespace-nowrap group-hover:scale-105 transition"
|
||||
style={{ left: `${Math.min(94, Math.max(6, pos))}%` }}
|
||||
>
|
||||
<Coins className="w-2.5 h-2.5 mr-1 text-cyan-400" />
|
||||
{formatZAR(act.amount)}
|
||||
return (
|
||||
<div
|
||||
key={act.id || idx}
|
||||
className="group relative flex items-center p-2.5 rounded-xl bg-slate-900/80 hover:bg-slate-800/90 border border-slate-800 hover:border-slate-700 transition cursor-pointer"
|
||||
onClick={onManageActivities}
|
||||
>
|
||||
<div className="w-56 shrink-0 pr-3 border-r border-slate-800 truncate">
|
||||
<span className="text-xs font-semibold text-white block truncate group-hover:text-cyan-300 transition">
|
||||
{act.title}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">{act.category || 'Operations'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative h-7 mx-4 flex items-center">
|
||||
<div className="w-full h-1 bg-slate-800 rounded-full" />
|
||||
<div
|
||||
className="absolute h-5 rounded-lg bg-cyan-500/20 border border-cyan-400/60 px-2.5 flex items-center text-[10px] font-bold text-cyan-300 shadow-md shadow-cyan-500/20 -translate-x-1/2 whitespace-nowrap group-hover:scale-105 transition"
|
||||
style={{ left: `${Math.min(95, Math.max(5, pos))}%` }}
|
||||
>
|
||||
<Coins className="w-3 h-3 mr-1 text-cyan-400" />
|
||||
{formatZAR(act.amount)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-32 shrink-0 text-right font-mono text-xs text-slate-400">
|
||||
{formatDate(act.performedAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="w-28 shrink-0 text-right font-mono text-[10px] text-slate-400">
|
||||
{formatDate(act.performedAt)}
|
||||
</div>
|
||||
{activities.length > 5 && (
|
||||
<div className="text-center pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageActivities}
|
||||
className="text-xs text-cyan-400 hover:text-cyan-300 font-semibold hover:underline cursor-pointer"
|
||||
>
|
||||
+ View all {activities.length} work breakdown tasks →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{activities.length > 4 && (
|
||||
<div className="text-center pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageActivities}
|
||||
className="text-[11px] text-cyan-400 hover:text-cyan-300 font-semibold hover:underline cursor-pointer"
|
||||
>
|
||||
+ View all {activities.length} work activities →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Summary KPI Bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-3 pt-2">
|
||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Project Duration</span>
|
||||
<span className="text-sm font-bold text-white mt-0.5 block">
|
||||
{timelineData.totalDurationDays} Days
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{formatDate(project.startedAt)} → {formatDate(project.endedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Milestone Progress</span>
|
||||
<div className="flex items-baseline gap-1.5 mt-0.5">
|
||||
<span className="text-sm font-bold text-emerald-400">{milestoneProgress}%</span>
|
||||
<span className="text-[10px] text-slate-400">({verifiedMilestonesCount}/{milestones.length})</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-900 rounded-full mt-1.5 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full transition-all"
|
||||
style={{ width: `${milestoneProgress}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Work Breakdown Cost</span>
|
||||
<span className="text-sm font-bold text-cyan-400 mt-0.5 block font-mono">
|
||||
{formatZAR(totalActivityBudget)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">{activities.length} total tasks</span>
|
||||
{/* BOTTOM NAVIGATION ARROWS (EXACTLY WHERE RED ARROWS WERE IN USER'S SCREENSHOT) */}
|
||||
{/* Bottom Left Navigation Arrow Button */}
|
||||
<div className="absolute bottom-3.5 left-4 z-20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScrollLeft}
|
||||
aria-label="Shift timeline left"
|
||||
title="Shift timeline left (360px)"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/95 hover:bg-blue-600 text-slate-200 hover:text-white border border-slate-700 hover:border-blue-500 shadow-2xl backdrop-blur-md transition-all cursor-pointer group active:scale-95"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 text-blue-400 group-hover:text-white group-hover:-translate-x-0.5 transition-transform" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider">Earlier</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Governance State</span>
|
||||
<div className="mt-1">
|
||||
{(() => {
|
||||
const b = getApprovalStatusBadge(project.status);
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${b.className}`}>
|
||||
{b.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 mt-0.5 block">Statutory Clearance</span>
|
||||
</div>
|
||||
|
||||
{/* Total Value of Approved Project Budgets Widget */}
|
||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Approved Budget Value</span>
|
||||
<span className="text-sm font-bold text-emerald-400 mt-0.5 block font-mono">
|
||||
{formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{approvedBudgetsCount > 0
|
||||
? `${approvedBudgetsCount} approved budget line${approvedBudgetsCount === 1 ? '' : 's'}`
|
||||
: 'Allocated Project Budget'}
|
||||
</span>
|
||||
{/* Bottom Right Navigation Arrow Button */}
|
||||
<div className="absolute bottom-3.5 right-4 z-20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScrollRight}
|
||||
aria-label="Shift timeline right"
|
||||
title="Shift timeline right (360px)"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/95 hover:bg-blue-600 text-slate-200 hover:text-white border border-slate-700 hover:border-blue-500 shadow-2xl backdrop-blur-md transition-all cursor-pointer group active:scale-95"
|
||||
>
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider">Later</span>
|
||||
<ChevronRight className="w-4 h-4 text-blue-400 group-hover:text-white group-hover:translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user