Moved export and fixed data output Added project budget amount widget to project listing
433 lines
19 KiB
TypeScript
433 lines
19 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import {
|
|
Flag,
|
|
Calendar,
|
|
Clock,
|
|
Briefcase,
|
|
CheckCircle2,
|
|
AlertCircle,
|
|
TrendingUp,
|
|
Plus,
|
|
Coins,
|
|
} 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<ProjectTimelineGanttProps> = ({
|
|
project,
|
|
milestones,
|
|
activities,
|
|
approvedBudgetTotal = 0,
|
|
approvedBudgetsCount = 0,
|
|
onManageMilestones,
|
|
onManageActivities,
|
|
}) => {
|
|
// Compute timeline boundaries and items
|
|
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);
|
|
// Add a slight buffer (5% on each side) for aesthetic padding
|
|
const buffer = Math.max((endMs - startMs) * 0.05, 7 * 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;
|
|
}
|
|
|
|
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;
|
|
};
|
|
|
|
// Generate monthly ticks for ruler
|
|
const ticks: { label: string; percent: number; isStartOfYear: boolean }[] = [];
|
|
const currDate = new Date(startMs);
|
|
currDate.setDate(1);
|
|
currDate.setHours(0, 0, 0, 0);
|
|
|
|
while (currDate.getTime() <= endMs) {
|
|
if (currDate.getTime() >= startMs) {
|
|
ticks.push({
|
|
label: currDate.toLocaleDateString('en-GB', { month: 'short', year: '2-digit' }),
|
|
percent: getPercent(currDate.getTime()),
|
|
isStartOfYear: currDate.getMonth() === 0,
|
|
});
|
|
}
|
|
currDate.setMonth(currDate.getMonth() + 1);
|
|
}
|
|
|
|
// Sort milestones & activities by date
|
|
const sortedMilestones = [...milestones].sort(
|
|
(a, b) => new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime()
|
|
);
|
|
|
|
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;
|
|
|
|
return {
|
|
startMs,
|
|
endMs,
|
|
totalDurationDays: Math.round(totalDurationMs / (1000 * 60 * 60 * 24)),
|
|
getPercent,
|
|
ticks,
|
|
sortedMilestones,
|
|
sortedActivities,
|
|
todayPercent,
|
|
isTodayInRange,
|
|
};
|
|
}, [project, milestones, activities]);
|
|
|
|
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">
|
|
<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">
|
|
<Calendar className="w-4 h-4" />
|
|
</div>
|
|
<div>
|
|
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2">
|
|
Implementation Schedule & Timeline
|
|
<span className="text-[10px] normal-case font-normal text-slate-400">
|
|
({timelineData.totalDurationDays} Days Span)
|
|
</span>
|
|
</h4>
|
|
<p className="text-[11px] text-slate-400">
|
|
Chronological alignment of contractual deliverables and operational activities.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Legend & Actions */}
|
|
<div className="flex items-center gap-3 text-[11px] flex-wrap">
|
|
<div className="flex items-center gap-3 px-3 py-1 bg-slate-900/80 rounded-xl border border-slate-800 text-slate-400">
|
|
<span className="flex items-center gap-1.5">
|
|
<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">
|
|
<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">
|
|
<span className="w-2.5 h-2.5 rounded-sm bg-blue-500 inline-block shadow-sm shadow-blue-500/50" />
|
|
Work Activity
|
|
</span>
|
|
</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>
|
|
))}
|
|
</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>
|
|
)}
|
|
</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">
|
|
<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"
|
|
>
|
|
<Plus className="w-3 h-3" /> Add / Manage Milestones
|
|
</button>
|
|
</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}
|
|
>
|
|
{/* Flag Marker */}
|
|
<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'
|
|
}`}
|
|
>
|
|
<Flag className="w-3.5 h-3.5 fill-current" />
|
|
</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>
|
|
</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" />
|
|
{formatDate(m.dueAt)}
|
|
</span>
|
|
<span className={isApproved ? 'text-emerald-400 font-semibold' : 'text-amber-400'}>
|
|
{isApproved ? 'Verified' : 'Pending'}
|
|
</span>
|
|
</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">
|
|
<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"
|
|
>
|
|
<Plus className="w-3 h-3" /> Add / Manage Activities
|
|
</button>
|
|
</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.
|
|
</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>
|
|
|
|
<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)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-28 shrink-0 text-right font-mono text-[10px] text-slate-400">
|
|
{formatDate(act.performedAt)}
|
|
</div>
|
|
</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 lg: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 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>
|
|
</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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|