Pipeline UI/UX redesign: inline editing, flowchart, sidebar stepper
- 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>
This commit is contained in:
270
src/components/admin/pipeline/pipeline-flowchart.tsx
Normal file
270
src/components/admin/pipeline/pipeline-flowchart.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
334
src/components/admin/pipeline/stage-config-editor.tsx
Normal file
334
src/components/admin/pipeline/stage-config-editor.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { EditableCard } from '@/components/ui/editable-card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Inbox,
|
||||
Filter,
|
||||
ClipboardCheck,
|
||||
Trophy,
|
||||
Tv,
|
||||
BarChart3,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
||||
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
|
||||
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
|
||||
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
|
||||
|
||||
import {
|
||||
defaultIntakeConfig,
|
||||
defaultFilterConfig,
|
||||
defaultEvaluationConfig,
|
||||
defaultLiveConfig,
|
||||
} from '@/lib/pipeline-defaults'
|
||||
|
||||
import type {
|
||||
IntakeConfig,
|
||||
FilterConfig,
|
||||
EvaluationConfig,
|
||||
LiveFinalConfig,
|
||||
} from '@/types/pipeline-wizard'
|
||||
|
||||
type StageConfigEditorProps = {
|
||||
stageId: string
|
||||
stageName: string
|
||||
stageType: string
|
||||
configJson: Record<string, unknown> | null
|
||||
onSave: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
const stageIcons: Record<string, React.ReactNode> = {
|
||||
INTAKE: <Inbox className="h-4 w-4" />,
|
||||
FILTER: <Filter className="h-4 w-4" />,
|
||||
EVALUATION: <ClipboardCheck className="h-4 w-4" />,
|
||||
SELECTION: <Trophy className="h-4 w-4" />,
|
||||
LIVE_FINAL: <Tv className="h-4 w-4" />,
|
||||
RESULTS: <BarChart3 className="h-4 w-4" />,
|
||||
}
|
||||
|
||||
function ConfigSummary({
|
||||
stageType,
|
||||
configJson,
|
||||
}: {
|
||||
stageType: string
|
||||
configJson: Record<string, unknown> | null
|
||||
}) {
|
||||
if (!configJson) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No configuration set
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
switch (stageType) {
|
||||
case 'INTAKE': {
|
||||
const config = configJson as unknown as IntakeConfig
|
||||
return (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Submission Window:</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{config.submissionWindowEnabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Late Policy:</span>
|
||||
<span className="capitalize">{config.lateSubmissionPolicy}</span>
|
||||
{config.lateGraceHours > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
({config.lateGraceHours}h grace)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">File Requirements:</span>
|
||||
<span>{config.fileRequirements?.length ?? 0} configured</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'FILTER': {
|
||||
const config = configJson as unknown as FilterConfig
|
||||
return (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Rules:</span>
|
||||
<span>{config.rules?.length ?? 0} eligibility rules</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">AI Screening:</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{config.aiRubricEnabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
{config.aiRubricEnabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Confidence:</span>
|
||||
<span>
|
||||
High {config.aiConfidenceThresholds?.high ?? 0.85} / Med{' '}
|
||||
{config.aiConfidenceThresholds?.medium ?? 0.6}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Manual Queue:</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{config.manualQueueEnabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'EVALUATION': {
|
||||
const config = configJson as unknown as EvaluationConfig
|
||||
return (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Required Reviews:</span>
|
||||
<span>{config.requiredReviews ?? 3}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Load per Juror:</span>
|
||||
<span>
|
||||
{config.minLoadPerJuror ?? 5} - {config.maxLoadPerJuror ?? 20}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Overflow Policy:</span>
|
||||
<span className="capitalize">
|
||||
{(config.overflowPolicy ?? 'queue').replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'SELECTION': {
|
||||
return (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Ranking Method:</span>
|
||||
<span className="capitalize">
|
||||
{((configJson.rankingMethod as string) ?? 'score_average').replace(
|
||||
/_/g,
|
||||
' '
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Tie Breaker:</span>
|
||||
<span className="capitalize">
|
||||
{((configJson.tieBreaker as string) ?? 'admin_decides').replace(
|
||||
/_/g,
|
||||
' '
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{configJson.finalistCount != null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Finalist Count:</span>
|
||||
<span>{String(configJson.finalistCount)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'LIVE_FINAL': {
|
||||
const config = configJson as unknown as LiveFinalConfig
|
||||
return (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Jury Voting:</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{config.juryVotingEnabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Audience Voting:</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{config.audienceVotingEnabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
{config.audienceVotingEnabled && (
|
||||
<span className="text-muted-foreground">
|
||||
({config.audienceVoteWeight}% weight)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Reveal:</span>
|
||||
<span className="capitalize">{config.revealPolicy ?? 'ceremony'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'RESULTS': {
|
||||
return (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Publication:</span>
|
||||
<span className="capitalize">
|
||||
{((configJson.publicationMode as string) ?? 'manual').replace(
|
||||
/_/g,
|
||||
' '
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Show Scores:</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{configJson.showDetailedScores ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Configuration view not available for this stage type
|
||||
</p>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function StageConfigEditor({
|
||||
stageId,
|
||||
stageName,
|
||||
stageType,
|
||||
configJson,
|
||||
onSave,
|
||||
isSaving = false,
|
||||
}: StageConfigEditorProps) {
|
||||
const [localConfig, setLocalConfig] = useState<Record<string, unknown>>(
|
||||
() => configJson ?? {}
|
||||
)
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
await onSave(stageId, localConfig)
|
||||
}, [stageId, localConfig, onSave])
|
||||
|
||||
const renderEditor = () => {
|
||||
switch (stageType) {
|
||||
case 'INTAKE': {
|
||||
const config = {
|
||||
...defaultIntakeConfig(),
|
||||
...(localConfig as object),
|
||||
} as IntakeConfig
|
||||
return (
|
||||
<IntakeSection
|
||||
config={config}
|
||||
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'FILTER': {
|
||||
const config = {
|
||||
...defaultFilterConfig(),
|
||||
...(localConfig as object),
|
||||
} as FilterConfig
|
||||
return (
|
||||
<FilteringSection
|
||||
config={config}
|
||||
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'EVALUATION': {
|
||||
const config = {
|
||||
...defaultEvaluationConfig(),
|
||||
...(localConfig as object),
|
||||
} as EvaluationConfig
|
||||
return (
|
||||
<AssignmentSection
|
||||
config={config}
|
||||
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'LIVE_FINAL': {
|
||||
const config = {
|
||||
...defaultLiveConfig(),
|
||||
...(localConfig as object),
|
||||
} as LiveFinalConfig
|
||||
return (
|
||||
<LiveFinalsSection
|
||||
config={config}
|
||||
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'SELECTION':
|
||||
case 'RESULTS':
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground py-4 text-center">
|
||||
Configuration for {stageType.replace('_', ' ')} stages is managed
|
||||
through the stage settings.
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EditableCard
|
||||
title={`${stageName} Configuration`}
|
||||
icon={stageIcons[stageType]}
|
||||
summary={<ConfigSummary stageType={stageType} configJson={configJson} />}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
>
|
||||
{renderEditor()}
|
||||
</EditableCard>
|
||||
)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ChevronDown, CheckCircle2, AlertCircle, Info } from 'lucide-react'
|
||||
|
||||
type WizardSectionProps = {
|
||||
title: string
|
||||
description?: string
|
||||
helpText?: string
|
||||
stepNumber: number
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
isValid: boolean
|
||||
hasErrors?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function WizardSection({
|
||||
title,
|
||||
description,
|
||||
helpText,
|
||||
stepNumber,
|
||||
isOpen,
|
||||
onToggle,
|
||||
isValid,
|
||||
hasErrors,
|
||||
children,
|
||||
}: WizardSectionProps) {
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={onToggle}>
|
||||
<Card className={cn(isOpen && 'ring-1 ring-ring')}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer select-none hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant={isValid ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-7 w-7 shrink-0 rounded-full p-0 flex items-center justify-center text-xs font-bold',
|
||||
isValid
|
||||
? 'bg-emerald-500 text-white hover:bg-emerald-500'
|
||||
: hasErrors
|
||||
? 'border-destructive text-destructive'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
{isValid ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : hasErrors ? (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
) : (
|
||||
stepNumber
|
||||
)}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
{description && !isOpen && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
isOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="pt-0">
|
||||
{helpText && (
|
||||
<div className="bg-blue-50 text-blue-700 dark:bg-blue-950/30 dark:text-blue-300 text-sm rounded-md p-3 mb-4 flex items-start gap-2">
|
||||
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>{helpText}</span>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user