'use client' import { useRef, useEffect, useState, useCallback } from 'react' import { motion } from 'motion/react' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' type StageNode = { id: string name: string stageType: string sortOrder: number _count?: { projectStageStates: number } } type FlowchartTrack = { id: string name: string kind: string sortOrder: number stages: StageNode[] } type PipelineFlowchartProps = { tracks: FlowchartTrack[] selectedStageId?: string | null onStageSelect?: (stageId: string) => void className?: string compact?: boolean } const stageTypeColors: Record = { INTAKE: { bg: '#eff6ff', border: '#93c5fd', text: '#1d4ed8', glow: '#3b82f6' }, FILTER: { bg: '#fffbeb', border: '#fcd34d', text: '#b45309', glow: '#f59e0b' }, EVALUATION: { bg: '#faf5ff', border: '#c084fc', text: '#7e22ce', glow: '#a855f7' }, SELECTION: { bg: '#fff1f2', border: '#fda4af', text: '#be123c', glow: '#f43f5e' }, LIVE_FINAL: { bg: '#ecfdf5', border: '#6ee7b7', text: '#047857', glow: '#10b981' }, RESULTS: { bg: '#ecfeff', border: '#67e8f9', text: '#0e7490', glow: '#06b6d4' }, } const NODE_WIDTH = 140 const NODE_HEIGHT = 70 const NODE_GAP = 32 const ARROW_SIZE = 6 const TRACK_LABEL_HEIGHT = 28 const TRACK_GAP = 20 export function PipelineFlowchart({ tracks, selectedStageId, onStageSelect, className, compact = false, }: PipelineFlowchartProps) { const containerRef = useRef(null) const [hoveredStageId, setHoveredStageId] = useState(null) const sortedTracks = [...tracks].sort((a, b) => a.sortOrder - b.sortOrder) // Calculate dimensions const nodeW = compact ? 100 : NODE_WIDTH const nodeH = compact ? 50 : NODE_HEIGHT const gap = compact ? 20 : NODE_GAP const maxStages = Math.max(...sortedTracks.map((t) => t.stages.length), 1) const totalWidth = maxStages * nodeW + (maxStages - 1) * gap + 40 const totalHeight = sortedTracks.length * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) - TRACK_GAP + 20 const getNodePosition = useCallback( (trackIndex: number, stageIndex: number) => { const x = 20 + stageIndex * (nodeW + gap) const y = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) + TRACK_LABEL_HEIGHT return { x, y } }, [nodeW, nodeH, gap] ) return (
{/* Glow filter for selected node */} {sortedTracks.map((track, trackIndex) => { const sortedStages = [...track.stages].sort( (a, b) => a.sortOrder - b.sortOrder ) const trackY = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) return ( {/* Track label */} {track.name} {track.kind !== 'MAIN' && ` (${track.kind})`} {/* Arrows between stages */} {sortedStages.map((stage, stageIndex) => { if (stageIndex === 0) return null const from = getNodePosition(trackIndex, stageIndex - 1) const to = getNodePosition(trackIndex, stageIndex) const arrowY = from.y + nodeH / 2 return ( ) })} {/* Stage nodes */} {sortedStages.map((stage, stageIndex) => { const pos = getNodePosition(trackIndex, stageIndex) const isSelected = selectedStageId === stage.id const isHovered = hoveredStageId === stage.id const colors = stageTypeColors[stage.stageType] ?? { bg: '#f8fafc', border: '#cbd5e1', text: '#475569', glow: '#64748b', } const projectCount = stage._count?.projectStageStates ?? 0 return ( onStageSelect?.(stage.id)} onMouseEnter={() => setHoveredStageId(stage.id)} onMouseLeave={() => setHoveredStageId(null)} className={cn(onStageSelect && 'cursor-pointer')} filter={isSelected ? 'url(#selectedGlow)' : undefined} > {/* Selection ring */} {isSelected && ( )} {/* Node background */} {/* Stage name */} {stage.name.length > (compact ? 12 : 16) ? stage.name.slice(0, compact ? 10 : 14) + '...' : stage.name} {/* Type badge */} {stage.stageType.replace('_', ' ')} {/* Project count */} {!compact && projectCount > 0 && ( <> {projectCount} )} ) })} ) })}
{/* Scroll hint gradient for mobile */} {totalWidth > 400 && (
)}
) }