505 lines
29 KiB
TypeScript
505 lines
29 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/Components/ui/card';
|
|
import { Badge } from '@/Components/ui/badge';
|
|
import { TrendingUp, Calendar, CheckCircle2, Clock, Activity, Flag, Layers, CheckCircle } from 'lucide-react';
|
|
|
|
interface EvmPoint {
|
|
date: string;
|
|
full_date: string;
|
|
is_today?: boolean;
|
|
planned_pv: number;
|
|
actual_ev: number | null;
|
|
schedule_variance: number | null;
|
|
spi: number | null;
|
|
}
|
|
|
|
interface MilestoneTurnoverItem {
|
|
id: number;
|
|
name: string;
|
|
description?: string;
|
|
weight_percentage: number;
|
|
planned_date: string | null;
|
|
actual_date: string | null;
|
|
tasks_count: number;
|
|
completed_tasks_count: number;
|
|
progress_percentage: number;
|
|
is_turnovered: boolean;
|
|
turnover_status: string;
|
|
turnover_status_color: 'emerald' | 'amber' | 'blue' | 'rose' | 'indigo' | 'slate';
|
|
days_variance: number;
|
|
}
|
|
|
|
interface EvmSummary {
|
|
planned_pv: number;
|
|
actual_ev: number;
|
|
schedule_variance: number;
|
|
spi: number;
|
|
status: string;
|
|
target_end_date: string;
|
|
projected_end_date: string;
|
|
days_variance: number;
|
|
is_completed?: boolean;
|
|
}
|
|
|
|
interface Props {
|
|
evmData: {
|
|
timeSeries: EvmPoint[];
|
|
summary: EvmSummary;
|
|
milestones?: MilestoneTurnoverItem[];
|
|
};
|
|
}
|
|
|
|
export default function EvmSCurveChart({ evmData }: Props) {
|
|
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
|
|
|
if (!evmData || !evmData.timeSeries || evmData.timeSeries.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const { timeSeries, summary, milestones } = evmData;
|
|
const width = 800;
|
|
const height = 320;
|
|
const padding = 45;
|
|
|
|
const maxVal = 100;
|
|
const pointsCount = timeSeries.length;
|
|
|
|
const getX = (index: number) => padding + (index / Math.max(1, pointsCount - 1)) * (width - 2 * padding);
|
|
const getY = (val: number) => height - padding - (Math.max(0, Math.min(100, val)) / maxVal) * (height - 2 * padding);
|
|
|
|
// 1. Build SVG path string for Planned PV
|
|
const pvCoords = timeSeries.map((pt, i) => ({ x: getX(i), y: getY(pt.planned_pv), pt, idx: i }));
|
|
const pvPath = pvCoords.reduce((acc, coord, i) => (i === 0 ? `M ${coord.x} ${coord.y}` : `${acc} L ${coord.x} ${coord.y}`), '');
|
|
const pvAreaPath = `${pvPath} L ${getX(pointsCount - 1)} ${getY(0)} L ${getX(0)} ${getY(0)} Z`;
|
|
|
|
// 2. Build SVG path string for Actual EV
|
|
const actualPoints = timeSeries
|
|
.map((pt, idx) => ({ pt, idx, x: getX(idx), y: getY(pt.actual_ev ?? 0) }))
|
|
.filter(item => item.pt.actual_ev !== null && item.pt.actual_ev !== undefined);
|
|
|
|
// If actualPoints does not start at index 0, anchor it to (0, 0%)
|
|
const evDrawCoords: { x: number; y: number; pt: EvmPoint; idx: number }[] = [];
|
|
if (actualPoints.length > 0) {
|
|
if (actualPoints[0].idx > 0) {
|
|
evDrawCoords.push({ x: getX(0), y: getY(0), pt: { ...timeSeries[0], actual_ev: 0 }, idx: 0 });
|
|
}
|
|
evDrawCoords.push(...actualPoints);
|
|
}
|
|
|
|
const evPath = evDrawCoords.reduce((acc, coord, i) => (i === 0 ? `M ${coord.x} ${coord.y}` : `${acc} L ${coord.x} ${coord.y}`), '');
|
|
const lastEvCoord = evDrawCoords.length > 0 ? evDrawCoords[evDrawCoords.length - 1] : null;
|
|
const evAreaPath = evDrawCoords.length > 0
|
|
? `${evPath} L ${lastEvCoord?.x} ${getY(0)} L ${evDrawCoords[0].x} ${getY(0)} Z`
|
|
: '';
|
|
|
|
const isAhead = summary.schedule_variance >= 0;
|
|
const statusColor = isAhead
|
|
? 'bg-emerald-100 text-emerald-800 border-emerald-300'
|
|
: summary.schedule_variance > -5
|
|
? 'bg-amber-100 text-amber-800 border-amber-300'
|
|
: 'bg-rose-100 text-rose-800 border-rose-300';
|
|
|
|
const activePoint = hoveredIdx !== null ? timeSeries[hoveredIdx] : null;
|
|
|
|
const getStatusBadgeClass = (color: string) => {
|
|
switch (color) {
|
|
case 'emerald': return 'bg-emerald-50 text-emerald-700 border-emerald-200';
|
|
case 'blue': return 'bg-blue-50 text-blue-700 border-blue-200';
|
|
case 'amber': return 'bg-amber-50 text-amber-700 border-amber-200';
|
|
case 'rose': return 'bg-rose-50 text-rose-700 border-rose-200';
|
|
case 'indigo': return 'bg-indigo-50 text-indigo-700 border-indigo-200';
|
|
default: return 'bg-slate-50 text-slate-700 border-slate-200';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Executive KPI Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<Card className="shadow-sm border-slate-200/80 hover:border-blue-300 transition-colors">
|
|
<CardContent className="p-5 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Scheduled Baseline (PV)</p>
|
|
<h3 className="text-2xl font-black text-slate-900 mt-1">{summary.planned_pv}%</h3>
|
|
<p className="text-xs text-slate-400 mt-0.5">Estimated progress as of today</p>
|
|
</div>
|
|
<div className="p-3 bg-blue-50 text-blue-600 rounded-xl border border-blue-100">
|
|
<Clock className="w-6 h-6" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="shadow-sm border-slate-200/80 hover:border-emerald-300 transition-colors">
|
|
<CardContent className="p-5 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Real-Time Progress (EV)</p>
|
|
<h3 className="text-2xl font-black text-emerald-600 mt-1">{summary.actual_ev}%</h3>
|
|
<p className="text-xs text-slate-400 mt-0.5">Verified actual field progress</p>
|
|
</div>
|
|
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-xl border border-emerald-100">
|
|
<CheckCircle2 className="w-6 h-6" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="shadow-sm border-slate-200/80 hover:border-slate-300 transition-colors">
|
|
<CardContent className="p-5 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Schedule Performance (SPI)</p>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<h3 className="text-2xl font-black text-slate-900">{summary.spi}</h3>
|
|
<Badge variant="outline" className={`text-[11px] font-semibold px-2 py-0.5 border ${statusColor}`}>
|
|
{summary.status}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs text-slate-400 mt-0.5">
|
|
Schedule Variance: <strong className={summary.schedule_variance >= 0 ? "text-emerald-600" : "text-rose-600"}>
|
|
{summary.schedule_variance > 0 ? `+${summary.schedule_variance}%` : `${summary.schedule_variance}%`}
|
|
</strong>
|
|
</p>
|
|
</div>
|
|
<div className={`p-3 rounded-xl border ${isAhead ? 'bg-emerald-50 text-emerald-600 border-emerald-100' : 'bg-rose-50 text-rose-600 border-rose-100'}`}>
|
|
<TrendingUp className="w-6 h-6" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="shadow-sm border-slate-200/80 hover:border-amber-300 transition-colors">
|
|
<CardContent className="p-5 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
|
{summary.is_completed ? 'Actual vs Target End' : 'Target vs Forecasted End'}
|
|
</p>
|
|
<h3 className="text-base font-bold text-slate-900 mt-1">{summary.projected_end_date}</h3>
|
|
<p className="text-xs text-slate-400 mt-0.5">
|
|
Target: {summary.target_end_date}{' '}
|
|
{summary.days_variance > 0 ? (
|
|
<span className="text-rose-600 font-semibold">(+{summary.days_variance}d delay)</span>
|
|
) : summary.days_variance < 0 ? (
|
|
<span className="text-emerald-600 font-semibold">({Math.abs(summary.days_variance)}d ahead)</span>
|
|
) : (
|
|
<span className="text-slate-500 font-semibold">(On schedule)</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className={`p-3 rounded-xl border ${summary.is_completed ? 'bg-emerald-50 text-emerald-600 border-emerald-100' : 'bg-amber-50 text-amber-600 border-amber-100'}`}>
|
|
<Calendar className="w-6 h-6" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Interactive S-Curve Visual Card */}
|
|
<Card className="shadow-sm border-slate-200 overflow-hidden">
|
|
<CardHeader className="bg-slate-50/50 border-b border-slate-100 pb-4">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<div>
|
|
<CardTitle className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
|
<Activity className="w-5 h-5 text-blue-600" />
|
|
Earned Value S-Curve Baseline Comparison
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Contrasting Planned Schedule Curve (PV) against Verified Real-Time Field Accomplishments (EV)
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-xs font-medium bg-white px-3 py-1.5 rounded-lg border border-slate-200 shadow-2xs">
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-3.5 h-1.5 rounded-full bg-blue-500 inline-block"></span>
|
|
<span className="text-slate-700">Baseline Target (PV)</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-3.5 h-3.5 rounded-full bg-emerald-500 border-2 border-white shadow-xs inline-block"></span>
|
|
<span className="text-slate-700 font-semibold">Real-Time Actuals (EV)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-6 relative">
|
|
{/* Hover Tooltip Overlay */}
|
|
{activePoint && (
|
|
<div
|
|
className="absolute top-8 z-20 bg-slate-900/90 backdrop-blur-sm text-white px-3.5 py-2.5 rounded-xl shadow-lg border border-slate-700 text-xs pointer-events-none transition-all duration-150"
|
|
style={{
|
|
left: `${Math.min(width - 160, Math.max(padding + 20, getX(hoveredIdx ?? 0)))}px`,
|
|
transform: 'translateX(-50%)',
|
|
}}
|
|
>
|
|
<div className="font-bold text-slate-200 border-b border-slate-700 pb-1 mb-1.5 flex justify-between gap-4">
|
|
<span>{activePoint.full_date}</span>
|
|
{activePoint.is_today && <span className="text-emerald-400 font-semibold">[Today]</span>}
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-blue-300">Planned (PV):</span>
|
|
<span className="font-bold">{activePoint.planned_pv}%</span>
|
|
</div>
|
|
{activePoint.actual_ev !== null && (
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-emerald-300">Verified (EV):</span>
|
|
<span className="font-bold">{activePoint.actual_ev}%</span>
|
|
</div>
|
|
)}
|
|
{activePoint.schedule_variance !== null && (
|
|
<div className="flex justify-between gap-4 text-[11px] text-slate-300">
|
|
<span>Variance (SV):</span>
|
|
<span className={activePoint.schedule_variance >= 0 ? "text-emerald-400" : "text-rose-400"}>
|
|
{activePoint.schedule_variance > 0 ? `+${activePoint.schedule_variance}%` : `${activePoint.schedule_variance}%`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="w-full overflow-x-auto">
|
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto min-w-[650px] max-h-[380px]">
|
|
<defs>
|
|
<linearGradient id="pvGradient" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.12" />
|
|
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.0" />
|
|
</linearGradient>
|
|
<linearGradient id="evGradient" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#10b981" stopOpacity="0.25" />
|
|
<stop offset="100%" stopColor="#10b981" stopOpacity="0.0" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
{/* Horizontal Gridlines */}
|
|
{[0, 25, 50, 75, 100].map(val => (
|
|
<g key={val}>
|
|
<line
|
|
x1={padding}
|
|
y1={getY(val)}
|
|
x2={width - padding}
|
|
y2={getY(val)}
|
|
stroke="#e2e8f0"
|
|
strokeDasharray={val === 0 ? "none" : "3 3"}
|
|
strokeWidth={val === 0 ? "1.5" : "1"}
|
|
/>
|
|
<text x={padding - 10} y={getY(val) + 4} textAnchor="end" className="text-[11px] fill-slate-400 font-mono font-medium">
|
|
{val}%
|
|
</text>
|
|
</g>
|
|
))}
|
|
|
|
{/* Today Vertical Line Indicator */}
|
|
{timeSeries.map((pt, idx) => {
|
|
if (!pt.is_today) return null;
|
|
const todayX = getX(idx);
|
|
return (
|
|
<g key="today-indicator">
|
|
<line
|
|
x1={todayX}
|
|
y1={padding - 10}
|
|
x2={todayX}
|
|
y2={height - padding}
|
|
stroke="#10b981"
|
|
strokeWidth="1.5"
|
|
strokeDasharray="4 3"
|
|
strokeOpacity="0.8"
|
|
/>
|
|
<rect
|
|
x={todayX - 22}
|
|
y={padding - 22}
|
|
width="44"
|
|
height="16"
|
|
rx="4"
|
|
fill="#10b981"
|
|
/>
|
|
<text
|
|
x={todayX}
|
|
y={padding - 10}
|
|
textAnchor="middle"
|
|
className="text-[9px] font-bold fill-white tracking-wider uppercase"
|
|
>
|
|
Today
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
|
|
{/* Area Fills */}
|
|
{pvAreaPath && <path d={pvAreaPath} fill="url(#pvGradient)" />}
|
|
{evAreaPath && <path d={evAreaPath} fill="url(#evGradient)" />}
|
|
|
|
{/* Planned Baseline PV Path */}
|
|
<path
|
|
d={pvPath}
|
|
fill="none"
|
|
stroke="#3b82f6"
|
|
strokeWidth="2.5"
|
|
strokeDasharray="5 3"
|
|
/>
|
|
|
|
{/* Real-Time Actuals EV Path */}
|
|
{evPath && (
|
|
<path
|
|
d={evPath}
|
|
fill="none"
|
|
stroke="#10b981"
|
|
strokeWidth="3.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
)}
|
|
|
|
{/* Planned PV Data Points */}
|
|
{pvCoords.map((coord) => (
|
|
<circle
|
|
key={`pv-${coord.pt.full_date}`}
|
|
cx={coord.x}
|
|
cy={coord.y}
|
|
r={hoveredIdx === coord.idx ? "4.5" : "3"}
|
|
className="fill-blue-500 stroke-white stroke-1.5 transition-all"
|
|
/>
|
|
))}
|
|
|
|
{/* Real-Time Actuals EV Data Points */}
|
|
{evDrawCoords.map((coord) => (
|
|
<g key={`ev-${coord.pt.full_date}`}>
|
|
{coord.pt.is_today && (
|
|
<circle
|
|
cx={coord.x}
|
|
cy={coord.y}
|
|
r="10"
|
|
className="fill-emerald-400/20 animate-ping"
|
|
/>
|
|
)}
|
|
<circle
|
|
cx={coord.x}
|
|
cy={coord.y}
|
|
r={coord.pt.is_today ? "6" : hoveredIdx === coord.idx ? "5" : "4"}
|
|
className="fill-emerald-600 stroke-white stroke-2 shadow-md cursor-pointer transition-all"
|
|
/>
|
|
</g>
|
|
))}
|
|
|
|
{/* Interactive Hover Columns */}
|
|
{timeSeries.map((pt, idx) => (
|
|
<rect
|
|
key={`hover-${pt.full_date}`}
|
|
x={getX(idx) - (width / pointsCount) / 2}
|
|
y={padding}
|
|
width={width / pointsCount}
|
|
height={height - 2 * padding}
|
|
fill="transparent"
|
|
className="cursor-pointer"
|
|
onMouseEnter={() => setHoveredIdx(idx)}
|
|
onMouseLeave={() => setHoveredIdx(null)}
|
|
/>
|
|
))}
|
|
|
|
{/* X-Axis Dates */}
|
|
{timeSeries.map((pt, idx) => (
|
|
<text
|
|
key={`label-${pt.full_date}`}
|
|
x={getX(idx)}
|
|
y={height - 15}
|
|
textAnchor="middle"
|
|
className={`text-[10px] font-medium font-mono ${pt.is_today ? 'fill-emerald-600 font-bold' : hoveredIdx === idx ? 'fill-slate-900 font-semibold' : 'fill-slate-400'}`}
|
|
>
|
|
{pt.date}
|
|
</text>
|
|
))}
|
|
</svg>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Milestone Turnover & Stage Gate Delivery Coordination Card */}
|
|
{milestones && milestones.length > 0 && (
|
|
<Card className="shadow-sm border-slate-200 overflow-hidden">
|
|
<CardHeader className="bg-slate-50/50 border-b border-slate-100 pb-4">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
|
<div>
|
|
<CardTitle className="text-base font-bold text-slate-800 flex items-center gap-2">
|
|
<Flag className="w-4 h-4 text-emerald-600" />
|
|
Milestone Turnover & Stage Gate Coordination
|
|
</CardTitle>
|
|
<CardDescription className="text-xs text-slate-500">
|
|
Tasks drive granular field progress, while milestones govern stage-gate turnover and handover deadlines.
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="bg-white text-slate-700 border-slate-200 text-xs font-semibold px-2.5 py-1">
|
|
{milestones.filter(m => m.is_turnovered).length} of {milestones.length} Turnovered
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0 divide-y divide-slate-100">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs text-left">
|
|
<thead className="bg-slate-50 text-slate-500 font-semibold uppercase tracking-wider text-[10px]">
|
|
<tr>
|
|
<th className="px-5 py-3">Milestone Stage</th>
|
|
<th className="px-4 py-3 text-center">Weight</th>
|
|
<th className="px-4 py-3">Task Completion</th>
|
|
<th className="px-4 py-3">Scheduled Turnover</th>
|
|
<th className="px-4 py-3">Actual Turnover</th>
|
|
<th className="px-5 py-3 text-right">Gate Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100 bg-white font-medium text-slate-700">
|
|
{milestones.map((m) => (
|
|
<tr key={m.id} className="hover:bg-slate-50/70 transition-colors">
|
|
<td className="px-5 py-3.5">
|
|
<div className="font-bold text-slate-900 flex items-center gap-2">
|
|
<Layers className="w-3.5 h-3.5 text-slate-400" />
|
|
{m.name}
|
|
</div>
|
|
{m.description && (
|
|
<p className="text-[11px] text-slate-400 mt-0.5 truncate max-w-xs">{m.description}</p>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3.5 text-center font-bold text-slate-800">
|
|
{m.weight_percentage}%
|
|
</td>
|
|
<td className="px-4 py-3.5 min-w-[160px]">
|
|
<div className="flex items-center justify-between text-[11px] mb-1">
|
|
<span className="text-slate-500 font-mono">
|
|
{m.completed_tasks_count}/{m.tasks_count} tasks
|
|
</span>
|
|
<span className="font-bold text-slate-900">{m.progress_percentage}%</span>
|
|
</div>
|
|
<div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full transition-all duration-300 rounded-full ${
|
|
m.progress_percentage >= 100 ? 'bg-emerald-500' : m.progress_percentage > 0 ? 'bg-blue-500' : 'bg-slate-300'
|
|
}`}
|
|
style={{ width: `${Math.min(100, Math.max(0, m.progress_percentage))}%` }}
|
|
/>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3.5 font-mono text-slate-600">
|
|
{m.planned_date || <span className="text-slate-400 italic">Not set</span>}
|
|
</td>
|
|
<td className="px-4 py-3.5 font-mono text-slate-600">
|
|
{m.actual_date ? (
|
|
<span className="text-emerald-700 font-semibold">{m.actual_date}</span>
|
|
) : (
|
|
<span className="text-slate-400 italic">Pending Handover</span>
|
|
)}
|
|
</td>
|
|
<td className="px-5 py-3.5 text-right">
|
|
<Badge
|
|
variant="outline"
|
|
className={`text-[11px] font-bold px-2.5 py-0.5 border ${getStatusBadgeClass(m.turnover_status_color)}`}
|
|
>
|
|
{m.turnover_status}
|
|
</Badge>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|