Refactored projects and milestones page

This commit is contained in:
2026-09-28 11:34:11 +02:00
parent 93b3416d2c
commit 8b68f7b99c
8 changed files with 3307 additions and 1185 deletions
@@ -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 &quot;Add / Manage Milestones&quot; 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 &rarr;
</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)} &rarr; {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>
);
};