Refactored projects and milestones page
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
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[];
|
||||
onManageMilestones: () => void;
|
||||
onManageActivities: () => void;
|
||||
}
|
||||
|
||||
export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
project,
|
||||
milestones,
|
||||
activities,
|
||||
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-[220px] bg-[#090d16] rounded-xl border border-slate-800/90 p-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-0 bottom-0 z-20 flex flex-col items-center pointer-events-none"
|
||||
style={{ left: `${timelineData.todayPercent}%` }}
|
||||
>
|
||||
<span className="px-1.5 py-0.5 text-[9px] font-bold text-amber-300 bg-amber-500/20 border border-amber-500/40 rounded-full whitespace-nowrap -translate-y-2 shadow-sm">
|
||||
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" />
|
||||
</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-3 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-20 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-4 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-800 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user