Files

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>
);
}