- Add InlineEditableText, EditableCard, SidebarStepper shared components - Add PipelineFlowchart (interactive SVG stage visualization) - Add StageConfigEditor and usePipelineInlineEdit hook - Redesign detail page: flowchart replaces nested tabs, inline editing - Redesign creation wizard: sidebar stepper replaces accordion sections - Enhance list page: status dots, track indicators, relative timestamps - Convert edit page to redirect (editing now inline on detail page) - Delete old WizardSection accordion component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
271 lines
9.4 KiB
TypeScript
271 lines
9.4 KiB
TypeScript
'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<string, { bg: string; border: string; text: string; glow: string }> = {
|
|
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<HTMLDivElement>(null)
|
|
const [hoveredStageId, setHoveredStageId] = useState<string | null>(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 (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn('overflow-x-auto rounded-lg border bg-card', className)}
|
|
>
|
|
<svg
|
|
width={totalWidth}
|
|
height={totalHeight}
|
|
viewBox={`0 0 ${totalWidth} ${totalHeight}`}
|
|
className="min-w-full"
|
|
>
|
|
<defs>
|
|
<marker
|
|
id="arrowhead"
|
|
markerWidth={ARROW_SIZE}
|
|
markerHeight={ARROW_SIZE}
|
|
refX={ARROW_SIZE}
|
|
refY={ARROW_SIZE / 2}
|
|
orient="auto"
|
|
>
|
|
<path
|
|
d={`M 0 0 L ${ARROW_SIZE} ${ARROW_SIZE / 2} L 0 ${ARROW_SIZE} Z`}
|
|
fill="#94a3b8"
|
|
/>
|
|
</marker>
|
|
{/* Glow filter for selected node */}
|
|
<filter id="selectedGlow" x="-20%" y="-20%" width="140%" height="140%">
|
|
<feGaussianBlur stdDeviation="3" result="blur" />
|
|
<feFlood floodColor="#3b82f6" floodOpacity="0.3" result="color" />
|
|
<feComposite in="color" in2="blur" operator="in" result="glow" />
|
|
<feMerge>
|
|
<feMergeNode in="glow" />
|
|
<feMergeNode in="SourceGraphic" />
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
|
|
{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 (
|
|
<g key={track.id}>
|
|
{/* Track label */}
|
|
<text
|
|
x={20}
|
|
y={trackY + 14}
|
|
className="fill-muted-foreground text-[11px] font-medium"
|
|
style={{ fontFamily: 'inherit' }}
|
|
>
|
|
{track.name}
|
|
{track.kind !== 'MAIN' && ` (${track.kind})`}
|
|
</text>
|
|
|
|
{/* 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 (
|
|
<line
|
|
key={`arrow-${stage.id}`}
|
|
x1={from.x + nodeW}
|
|
y1={arrowY}
|
|
x2={to.x - 2}
|
|
y2={arrowY}
|
|
stroke="#94a3b8"
|
|
strokeWidth={1.5}
|
|
markerEnd="url(#arrowhead)"
|
|
/>
|
|
)
|
|
})}
|
|
|
|
{/* 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 (
|
|
<g
|
|
key={stage.id}
|
|
onClick={() => onStageSelect?.(stage.id)}
|
|
onMouseEnter={() => setHoveredStageId(stage.id)}
|
|
onMouseLeave={() => setHoveredStageId(null)}
|
|
className={cn(onStageSelect && 'cursor-pointer')}
|
|
filter={isSelected ? 'url(#selectedGlow)' : undefined}
|
|
>
|
|
{/* Selection ring */}
|
|
{isSelected && (
|
|
<motion.rect
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
x={pos.x - 3}
|
|
y={pos.y - 3}
|
|
width={nodeW + 6}
|
|
height={nodeH + 6}
|
|
rx={10}
|
|
fill="none"
|
|
stroke={colors.glow}
|
|
strokeWidth={2}
|
|
strokeDasharray="none"
|
|
/>
|
|
)}
|
|
|
|
{/* Node background */}
|
|
<rect
|
|
x={pos.x}
|
|
y={pos.y}
|
|
width={nodeW}
|
|
height={nodeH}
|
|
rx={8}
|
|
fill={colors.bg}
|
|
stroke={isSelected ? colors.glow : colors.border}
|
|
strokeWidth={isSelected ? 2 : 1}
|
|
style={{
|
|
transition: 'stroke 0.15s, stroke-width 0.15s',
|
|
transform: isHovered && !isSelected ? 'scale(1.02)' : undefined,
|
|
transformOrigin: `${pos.x + nodeW / 2}px ${pos.y + nodeH / 2}px`,
|
|
}}
|
|
/>
|
|
|
|
{/* Stage name */}
|
|
<text
|
|
x={pos.x + nodeW / 2}
|
|
y={pos.y + (compact ? 20 : 24)}
|
|
textAnchor="middle"
|
|
fill={colors.text}
|
|
className={cn(compact ? 'text-[10px]' : 'text-xs', 'font-medium')}
|
|
style={{ fontFamily: 'inherit' }}
|
|
>
|
|
{stage.name.length > (compact ? 12 : 16)
|
|
? stage.name.slice(0, compact ? 10 : 14) + '...'
|
|
: stage.name}
|
|
</text>
|
|
|
|
{/* Type badge */}
|
|
<text
|
|
x={pos.x + nodeW / 2}
|
|
y={pos.y + (compact ? 34 : 40)}
|
|
textAnchor="middle"
|
|
fill={colors.text}
|
|
className="text-[9px]"
|
|
style={{ fontFamily: 'inherit', opacity: 0.7 }}
|
|
>
|
|
{stage.stageType.replace('_', ' ')}
|
|
</text>
|
|
|
|
{/* Project count */}
|
|
{!compact && projectCount > 0 && (
|
|
<>
|
|
<rect
|
|
x={pos.x + nodeW / 2 - 14}
|
|
y={pos.y + nodeH - 18}
|
|
width={28}
|
|
height={14}
|
|
rx={7}
|
|
fill={colors.border}
|
|
opacity={0.3}
|
|
/>
|
|
<text
|
|
x={pos.x + nodeW / 2}
|
|
y={pos.y + nodeH - 8}
|
|
textAnchor="middle"
|
|
fill={colors.text}
|
|
className="text-[9px] font-medium"
|
|
style={{ fontFamily: 'inherit' }}
|
|
>
|
|
{projectCount}
|
|
</text>
|
|
</>
|
|
)}
|
|
</g>
|
|
)
|
|
})}
|
|
</g>
|
|
)
|
|
})}
|
|
</svg>
|
|
</div>
|
|
)
|
|
}
|