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:
2026-02-14 01:54:56 +01:00
parent 70cfad7d46
commit 59f90ccc37
11 changed files with 1609 additions and 935 deletions

View File

@@ -6,10 +6,11 @@ import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { ArrowLeft, Loader2, Save, Rocket } from 'lucide-react'
import { ArrowLeft } from 'lucide-react'
import Link from 'next/link'
import { WizardSection } from '@/components/admin/pipeline/wizard-section'
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
import type { StepConfig } from '@/components/ui/sidebar-stepper'
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
@@ -32,7 +33,7 @@ export default function NewPipelinePage() {
const programId = searchParams.get('programId') || currentEdition?.id || ''
const [state, setState] = useState<WizardState>(() => defaultWizardState(programId))
const [openSection, setOpenSection] = useState(0)
const [currentStep, setCurrentStep] = useState(0)
const initialStateRef = useRef(JSON.stringify(state))
// Update programId in state when edition context loads
@@ -129,9 +130,9 @@ export default function NewPipelinePage() {
const validation = validateAll(state)
if (!validation.valid) {
toast.error('Please fix validation errors before saving')
// Open first section with errors
if (!validation.sections.basics.valid) setOpenSection(0)
else if (!validation.sections.tracks.valid) setOpenSection(2)
// Navigate to first section with errors
if (!validation.sections.basics.valid) setCurrentStep(0)
else if (!validation.sections.tracks.valid) setCurrentStep(2)
return
}
@@ -168,7 +169,8 @@ export default function NewPipelinePage() {
}
}
const isSaving = createMutation.isPending || publishMutation.isPending
const isSaving = createMutation.isPending && !publishMutation.isPending
const isSubmitting = publishMutation.isPending
if (!programId) {
return (
@@ -190,230 +192,161 @@ export default function NewPipelinePage() {
)
}
const sections = [
// Step configuration
const steps: StepConfig[] = [
{
title: 'Basics',
description: 'Pipeline name, slug, and program',
description: 'Pipeline name and program',
isValid: basicsValid,
},
{
title: 'Intake',
description: 'Submission windows and file requirements',
description: 'Submission window & files',
isValid: !!intakeStage,
},
{
title: 'Main Track Stages',
description: `${mainTrack?.stages.length ?? 0} stages configured`,
description: 'Configure pipeline stages',
isValid: tracksValid,
},
{
title: 'Filtering',
description: 'Gate rules and AI screening settings',
title: 'Screening',
description: 'Gate rules and AI screening',
isValid: !!filterStage,
},
{
title: 'Assignment',
description: 'Jury evaluation assignment strategy',
title: 'Evaluation',
description: 'Jury assignment strategy',
isValid: !!evalStage,
},
{
title: 'Awards',
description: `${state.tracks.filter((t) => t.kind === 'AWARD').length} award tracks`,
description: 'Special award tracks',
isValid: true, // Awards are optional
},
{
title: 'Live Finals',
description: 'Voting, cohorts, and reveal settings',
description: 'Voting and reveal settings',
isValid: !!liveStage,
},
{
title: 'Notifications',
description: 'Event notifications and override governance',
description: 'Event notifications',
isValid: true, // Always valid
},
{
title: 'Review & Publish',
description: 'Validation summary and publish controls',
title: 'Review & Create',
description: 'Validation summary',
isValid: allValid,
},
]
return (
<div className="space-y-6">
<div className="space-y-6 pb-8">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/rounds/pipelines">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Create Pipeline</h1>
<p className="text-sm text-muted-foreground">
Configure the full pipeline structure for project evaluation
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
disabled={isSaving || !allValid}
onClick={() => handleSave(false)}
>
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
Save Draft
</Button>
<Button
type="button"
disabled={isSaving || !allValid}
onClick={() => handleSave(true)}
>
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Rocket className="h-4 w-4 mr-2" />}
Save & Publish
<div className="flex items-center gap-3">
<Link href="/admin/rounds/pipelines">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Create Pipeline</h1>
<p className="text-sm text-muted-foreground">
Configure the full pipeline structure for project evaluation
</p>
</div>
</div>
{/* Wizard Sections */}
<div className="space-y-3">
{/* 0: Basics */}
<WizardSection
stepNumber={1}
title={sections[0].title}
description={sections[0].description}
isOpen={openSection === 0}
onToggle={() => setOpenSection(openSection === 0 ? -1 : 0)}
isValid={sections[0].isValid}
>
{/* Sidebar Stepper */}
<SidebarStepper
steps={steps}
currentStep={currentStep}
onStepChange={setCurrentStep}
onSave={() => handleSave(false)}
onSubmit={() => handleSave(true)}
isSaving={isSaving}
isSubmitting={isSubmitting}
saveLabel="Save Draft"
submitLabel="Save & Publish"
canSubmit={allValid}
>
{/* Step 0: Basics */}
<div>
<BasicsSection state={state} onChange={updateState} />
</WizardSection>
</div>
{/* 1: Intake */}
<WizardSection
stepNumber={2}
title={sections[1].title}
description={sections[1].description}
isOpen={openSection === 1}
onToggle={() => setOpenSection(openSection === 1 ? -1 : 1)}
isValid={sections[1].isValid}
>
{/* Step 1: Intake */}
<div>
<IntakeSection
config={intakeConfig}
onChange={(c) =>
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
</div>
{/* 2: Main Track Stages */}
<WizardSection
stepNumber={3}
title={sections[2].title}
description={sections[2].description}
isOpen={openSection === 2}
onToggle={() => setOpenSection(openSection === 2 ? -1 : 2)}
isValid={sections[2].isValid}
>
{/* Step 2: Main Track Stages */}
<div>
<MainTrackSection
stages={mainTrack?.stages ?? []}
onChange={updateMainTrackStages}
/>
</WizardSection>
</div>
{/* 3: Filtering */}
<WizardSection
stepNumber={4}
title={sections[3].title}
description={sections[3].description}
isOpen={openSection === 3}
onToggle={() => setOpenSection(openSection === 3 ? -1 : 3)}
isValid={sections[3].isValid}
>
{/* Step 3: Screening */}
<div>
<FilteringSection
config={filterConfig}
onChange={(c) =>
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
</div>
{/* 4: Assignment */}
<WizardSection
stepNumber={5}
title={sections[4].title}
description={sections[4].description}
isOpen={openSection === 4}
onToggle={() => setOpenSection(openSection === 4 ? -1 : 4)}
isValid={sections[4].isValid}
>
{/* Step 4: Evaluation */}
<div>
<AssignmentSection
config={evalConfig}
onChange={(c) =>
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
</div>
{/* 5: Awards */}
<WizardSection
stepNumber={6}
title={sections[5].title}
description={sections[5].description}
isOpen={openSection === 5}
onToggle={() => setOpenSection(openSection === 5 ? -1 : 5)}
isValid={sections[5].isValid}
>
<AwardsSection tracks={state.tracks} onChange={(tracks) => updateState({ tracks })} />
</WizardSection>
{/* Step 5: Awards */}
<div>
<AwardsSection
tracks={state.tracks}
onChange={(tracks) => updateState({ tracks })}
/>
</div>
{/* 6: Live Finals */}
<WizardSection
stepNumber={7}
title={sections[6].title}
description={sections[6].description}
isOpen={openSection === 6}
onToggle={() => setOpenSection(openSection === 6 ? -1 : 6)}
isValid={sections[6].isValid}
>
{/* Step 6: Live Finals */}
<div>
<LiveFinalsSection
config={liveConfig}
onChange={(c) =>
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
}
/>
</WizardSection>
</div>
{/* 7: Notifications */}
<WizardSection
stepNumber={8}
title={sections[7].title}
description={sections[7].description}
isOpen={openSection === 7}
onToggle={() => setOpenSection(openSection === 7 ? -1 : 7)}
isValid={sections[7].isValid}
>
{/* Step 7: Notifications */}
<div>
<NotificationsSection
config={state.notificationConfig}
onChange={(notificationConfig) => updateState({ notificationConfig })}
overridePolicy={state.overridePolicy}
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
/>
</WizardSection>
</div>
{/* 8: Review */}
<WizardSection
stepNumber={9}
title={sections[8].title}
description={sections[8].description}
isOpen={openSection === 8}
onToggle={() => setOpenSection(openSection === 8 ? -1 : 8)}
isValid={sections[8].isValid}
>
{/* Step 8: Review & Create */}
<div>
<ReviewSection state={state} />
</WizardSection>
</div>
</div>
</SidebarStepper>
</div>
)
}

View File

@@ -1,429 +1,11 @@
'use client'
import { redirect } from 'next/navigation'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, Loader2, Save } from 'lucide-react'
import Link from 'next/link'
import type { Route } from 'next'
import { WizardSection } from '@/components/admin/pipeline/wizard-section'
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
import {
defaultIntakeConfig,
defaultFilterConfig,
defaultEvaluationConfig,
defaultLiveConfig,
defaultNotificationConfig,
} from '@/lib/pipeline-defaults'
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
import type {
WizardState,
IntakeConfig,
FilterConfig,
EvaluationConfig,
LiveFinalConfig,
WizardTrackConfig,
} from '@/types/pipeline-wizard'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function pipelineToWizardState(pipeline: any): WizardState {
const settings = (pipeline.settingsJson as Record<string, unknown>) ?? {}
return {
name: pipeline.name,
slug: pipeline.slug,
programId: pipeline.programId,
settingsJson: settings,
tracks: (pipeline.tracks ?? []).map((t: any) => ({
id: t.id,
name: t.name,
slug: t.slug,
kind: t.kind as WizardTrackConfig['kind'],
sortOrder: t.sortOrder,
routingModeDefault: t.routingMode as WizardTrackConfig['routingModeDefault'],
decisionMode: t.decisionMode as WizardTrackConfig['decisionMode'],
stages: (t.stages ?? []).map((s: any) => ({
id: s.id,
name: s.name,
slug: s.slug,
stageType: s.stageType as WizardTrackConfig['stages'][0]['stageType'],
sortOrder: s.sortOrder,
configJson: (s.configJson as Record<string, unknown>) ?? {},
})),
awardConfig: t.specialAward
? {
name: t.specialAward.name,
description: t.specialAward.description ?? undefined,
scoringMode: t.specialAward.scoringMode as NonNullable<WizardTrackConfig['awardConfig']>['scoringMode'],
}
: undefined,
})),
notificationConfig:
(settings.notificationConfig as Record<string, boolean>) ??
defaultNotificationConfig(),
overridePolicy:
(settings.overridePolicy as Record<string, unknown>) ?? {
allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
},
}
type EditPipelinePageProps = {
params: Promise<{ id: string }>
}
export default function EditPipelinePage() {
const router = useRouter()
const params = useParams()
const pipelineId = params.id as string
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
id: pipelineId,
})
const [state, setState] = useState<WizardState | null>(null)
const [openSection, setOpenSection] = useState(0)
const initialStateRef = useRef<string>('')
// Initialize state from pipeline data
useEffect(() => {
if (pipeline && !state) {
const wizardState = pipelineToWizardState(pipeline)
setState(wizardState)
initialStateRef.current = JSON.stringify(wizardState)
}
}, [pipeline, state])
// Dirty tracking
useEffect(() => {
if (!state) return
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (JSON.stringify(state) !== initialStateRef.current) {
e.preventDefault()
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [state])
const updateState = useCallback((updates: Partial<WizardState>) => {
setState((prev) => (prev ? { ...prev, ...updates } : null))
}, [])
const updateStageConfig = useCallback(
(stageType: string, configJson: Record<string, unknown>) => {
setState((prev) => {
if (!prev) return null
return {
...prev,
tracks: prev.tracks.map((track) => {
if (track.kind !== 'MAIN') return track
return {
...track,
stages: track.stages.map((stage) =>
stage.stageType === stageType ? { ...stage, configJson } : stage
),
}
}),
}
})
},
[]
)
const updateMainTrackStages = useCallback(
(stages: WizardState['tracks'][0]['stages']) => {
setState((prev) => {
if (!prev) return null
return {
...prev,
tracks: prev.tracks.map((track) =>
track.kind === 'MAIN' ? { ...track, stages } : track
),
}
})
},
[]
)
const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
onSuccess: () => {
if (state) initialStateRef.current = JSON.stringify(state)
toast.success('Pipeline updated successfully')
router.push(`/admin/rounds/pipeline/${pipelineId}` as Route)
},
onError: (err) => toast.error(err.message),
})
if (isLoading || !state) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8" />
<div>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32 mt-1" />
</div>
</div>
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
)
}
const mainTrack = state.tracks.find((t) => t.kind === 'MAIN')
const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE')
const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER')
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
const intakeConfig = { ...defaultIntakeConfig(), ...(intakeStage?.configJson ?? {}) } as IntakeConfig
const filterConfig = { ...defaultFilterConfig(), ...(filterStage?.configJson ?? {}) } as FilterConfig
const evalConfig = { ...defaultEvaluationConfig(), ...(evalStage?.configJson ?? {}) } as EvaluationConfig
const liveConfig = { ...defaultLiveConfig(), ...(liveStage?.configJson ?? {}) } as LiveFinalConfig
const basicsValid = validateBasics(state).valid
const tracksValid = validateTracks(state.tracks).valid
const allValid = validateAll(state).valid
const isActive = pipeline?.status === 'ACTIVE'
const handleSave = async () => {
const validation = validateAll(state)
if (!validation.valid) {
toast.error('Please fix validation errors before saving')
if (!validation.sections.basics.valid) setOpenSection(0)
else if (!validation.sections.tracks.valid) setOpenSection(2)
return
}
await updateStructureMutation.mutateAsync({
id: pipelineId,
name: state.name,
slug: state.slug,
settingsJson: {
...state.settingsJson,
notificationConfig: state.notificationConfig,
overridePolicy: state.overridePolicy,
},
tracks: state.tracks.map((t) => ({
id: t.id,
name: t.name,
slug: t.slug,
kind: t.kind,
sortOrder: t.sortOrder,
routingModeDefault: t.routingModeDefault,
decisionMode: t.decisionMode,
stages: t.stages.map((s) => ({
id: s.id,
name: s.name,
slug: s.slug,
stageType: s.stageType,
sortOrder: s.sortOrder,
configJson: s.configJson,
})),
awardConfig: t.awardConfig,
})),
autoTransitions: true,
})
}
const isSaving = updateStructureMutation.isPending
const sections = [
{ title: 'Basics', description: 'Pipeline name, slug, and program', isValid: basicsValid },
{ title: 'Intake', description: 'Submission windows and file requirements', isValid: !!intakeStage },
{ title: 'Main Track Stages', description: `${mainTrack?.stages.length ?? 0} stages configured`, isValid: tracksValid },
{ title: 'Filtering', description: 'Gate rules and AI screening settings', isValid: !!filterStage },
{ title: 'Assignment', description: 'Jury evaluation assignment strategy', isValid: !!evalStage },
{ title: 'Awards', description: `${state.tracks.filter((t) => t.kind === 'AWARD').length} award tracks`, isValid: true },
{ title: 'Live Finals', description: 'Voting, cohorts, and reveal settings', isValid: !!liveStage },
{ title: 'Notifications', description: 'Event notifications and override governance', isValid: true },
{ title: 'Review', description: 'Validation summary', isValid: allValid },
]
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href={`/admin/rounds/pipeline/${pipelineId}` as Route}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Edit Pipeline</h1>
<p className="text-sm text-muted-foreground">
{pipeline?.name}
{isActive && ' (Active — some fields are locked)'}
</p>
</div>
</div>
<Button
type="button"
disabled={isSaving || !allValid}
onClick={handleSave}
>
{isSaving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
Save Changes
</Button>
</div>
{/* Wizard Sections */}
<div className="space-y-3">
<WizardSection
stepNumber={1}
title={sections[0].title}
description={sections[0].description}
isOpen={openSection === 0}
onToggle={() => setOpenSection(openSection === 0 ? -1 : 0)}
isValid={sections[0].isValid}
>
<BasicsSection state={state} onChange={updateState} isActive={isActive} />
</WizardSection>
<WizardSection
stepNumber={2}
title={sections[1].title}
description={sections[1].description}
isOpen={openSection === 1}
onToggle={() => setOpenSection(openSection === 1 ? -1 : 1)}
isValid={sections[1].isValid}
>
<IntakeSection
config={intakeConfig}
onChange={(c) =>
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
}
isActive={isActive}
/>
</WizardSection>
<WizardSection
stepNumber={3}
title={sections[2].title}
description={sections[2].description}
isOpen={openSection === 2}
onToggle={() => setOpenSection(openSection === 2 ? -1 : 2)}
isValid={sections[2].isValid}
>
<MainTrackSection
stages={mainTrack?.stages ?? []}
onChange={updateMainTrackStages}
isActive={isActive}
/>
</WizardSection>
<WizardSection
stepNumber={4}
title={sections[3].title}
description={sections[3].description}
isOpen={openSection === 3}
onToggle={() => setOpenSection(openSection === 3 ? -1 : 3)}
isValid={sections[3].isValid}
>
<FilteringSection
config={filterConfig}
onChange={(c) =>
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
}
isActive={isActive}
/>
</WizardSection>
<WizardSection
stepNumber={5}
title={sections[4].title}
description={sections[4].description}
isOpen={openSection === 4}
onToggle={() => setOpenSection(openSection === 4 ? -1 : 4)}
isValid={sections[4].isValid}
>
<AssignmentSection
config={evalConfig}
onChange={(c) =>
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
}
isActive={isActive}
/>
</WizardSection>
<WizardSection
stepNumber={6}
title={sections[5].title}
description={sections[5].description}
isOpen={openSection === 5}
onToggle={() => setOpenSection(openSection === 5 ? -1 : 5)}
isValid={sections[5].isValid}
>
<AwardsSection
tracks={state.tracks}
onChange={(tracks) => updateState({ tracks })}
isActive={isActive}
/>
</WizardSection>
<WizardSection
stepNumber={7}
title={sections[6].title}
description={sections[6].description}
isOpen={openSection === 6}
onToggle={() => setOpenSection(openSection === 6 ? -1 : 6)}
isValid={sections[6].isValid}
>
<LiveFinalsSection
config={liveConfig}
onChange={(c) =>
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
}
isActive={isActive}
/>
</WizardSection>
<WizardSection
stepNumber={8}
title={sections[7].title}
description={sections[7].description}
isOpen={openSection === 7}
onToggle={() => setOpenSection(openSection === 7 ? -1 : 7)}
isValid={sections[7].isValid}
>
<NotificationsSection
config={state.notificationConfig}
onChange={(notificationConfig) => updateState({ notificationConfig })}
overridePolicy={state.overridePolicy}
onOverridePolicyChange={(overridePolicy) =>
updateState({ overridePolicy })
}
isActive={isActive}
/>
</WizardSection>
<WizardSection
stepNumber={9}
title={sections[8].title}
description={sections[8].description}
isOpen={openSection === 8}
onToggle={() => setOpenSection(openSection === 8 ? -1 : 8)}
isValid={sections[8].isValid}
>
<ReviewSection state={state} />
</WizardSection>
</div>
</div>
)
export default async function EditPipelinePage({ params }: EditPipelinePageProps) {
const { id } = await params
// Editing now happens inline on the detail page
redirect(`/admin/rounds/pipeline/${id}` as never)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
@@ -10,12 +10,8 @@ import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
DropdownMenu,
DropdownMenuContent,
@@ -27,7 +23,6 @@ import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import {
ArrowLeft,
Edit,
MoreHorizontal,
Rocket,
Archive,
@@ -35,8 +30,14 @@ import {
Layers,
GitBranch,
Loader2,
ChevronDown,
} from 'lucide-react'
import { InlineEditableText } from '@/components/ui/inline-editable-text'
import { PipelineFlowchart } from '@/components/admin/pipeline/pipeline-flowchart'
import { StageConfigEditor } from '@/components/admin/pipeline/stage-config-editor'
import { usePipelineInlineEdit } from '@/hooks/use-pipeline-inline-edit'
import { IntakePanel } from '@/components/admin/pipeline/stage-panels/intake-panel'
import { FilterPanel } from '@/components/admin/pipeline/stage-panels/filter-panel'
import { EvaluationPanel } from '@/components/admin/pipeline/stage-panels/evaluation-panel'
@@ -51,15 +52,6 @@ const statusColors: Record<string, string> = {
CLOSED: 'bg-blue-100 text-blue-700',
}
const stageTypeColors: Record<string, string> = {
INTAKE: 'bg-blue-100 text-blue-700',
FILTER: 'bg-amber-100 text-amber-700',
EVALUATION: 'bg-purple-100 text-purple-700',
SELECTION: 'bg-rose-100 text-rose-700',
LIVE_FINAL: 'bg-emerald-100 text-emerald-700',
RESULTS: 'bg-cyan-100 text-cyan-700',
}
function StagePanel({
stageId,
stageType,
@@ -100,20 +92,14 @@ export default function PipelineDetailPage() {
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
const [selectedStageId, setSelectedStageId] = useState<string | null>(null)
const stagePanelRef = useRef<HTMLDivElement>(null)
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
id: pipelineId,
})
// Auto-select first track and stage
useEffect(() => {
if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) {
const firstTrack = pipeline.tracks[0]
setSelectedTrackId(firstTrack.id)
if (firstTrack.stages.length > 0) {
setSelectedStageId(firstTrack.stages[0].id)
}
}
}, [pipeline, selectedTrackId])
const { isUpdating, updatePipeline, updateStageConfig } =
usePipelineInlineEdit(pipelineId)
const publishMutation = trpc.pipeline.publish.useMutation({
onSuccess: () => toast.success('Pipeline published'),
@@ -125,6 +111,25 @@ export default function PipelineDetailPage() {
onError: (err) => toast.error(err.message),
})
// Auto-select first track and stage on load
useEffect(() => {
if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) {
const firstTrack = pipeline.tracks.sort((a, b) => a.sortOrder - b.sortOrder)[0]
setSelectedTrackId(firstTrack.id)
if (firstTrack.stages.length > 0) {
const firstStage = firstTrack.stages.sort((a, b) => a.sortOrder - b.sortOrder)[0]
setSelectedStageId(firstStage.id)
}
}
}, [pipeline, selectedTrackId])
// Scroll to stage panel when a stage is selected
useEffect(() => {
if (selectedStageId && stagePanelRef.current) {
stagePanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, [selectedStageId])
if (isLoading) {
return (
<div className="space-y-6">
@@ -170,12 +175,27 @@ export default function PipelineDetailPage() {
setSelectedTrackId(trackId)
const track = pipeline.tracks.find((t) => t.id === trackId)
if (track && track.stages.length > 0) {
setSelectedStageId(track.stages[0].id)
const firstStage = track.stages.sort((a, b) => a.sortOrder - b.sortOrder)[0]
setSelectedStageId(firstStage.id)
} else {
setSelectedStageId(null)
}
}
const handleStageSelect = (stageId: string) => {
setSelectedStageId(stageId)
}
const handleStatusChange = async (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => {
await updateMutation.mutateAsync({
id: pipelineId,
status: newStatus,
})
}
// Prepare flowchart data for the selected track
const flowchartTracks = selectedTrack ? [selectedTrack] : []
return (
<div className="space-y-6">
{/* Header */}
@@ -188,30 +208,69 @@ export default function PipelineDetailPage() {
</Link>
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold">{pipeline.name}</h1>
<Badge
variant="secondary"
className={cn(
'text-[10px]',
statusColors[pipeline.status] ?? ''
)}
>
{pipeline.status}
</Badge>
<InlineEditableText
value={pipeline.name}
onSave={(newName) => updatePipeline({ name: newName })}
variant="h1"
placeholder="Untitled Pipeline"
disabled={isUpdating}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors',
statusColors[pipeline.status] ?? '',
'hover:opacity-80'
)}
>
{pipeline.status}
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => handleStatusChange('DRAFT')}
disabled={pipeline.status === 'DRAFT' || updateMutation.isPending}
>
Draft
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleStatusChange('ACTIVE')}
disabled={pipeline.status === 'ACTIVE' || updateMutation.isPending}
>
Active
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleStatusChange('CLOSED')}
disabled={pipeline.status === 'CLOSED' || updateMutation.isPending}
>
Closed
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleStatusChange('ARCHIVED')}
disabled={pipeline.status === 'ARCHIVED' || updateMutation.isPending}
>
Archived
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-1 text-sm">
<span className="text-muted-foreground">slug:</span>
<InlineEditableText
value={pipeline.slug}
onSave={(newSlug) => updatePipeline({ slug: newSlug })}
variant="mono"
placeholder="pipeline-slug"
disabled={isUpdating}
/>
</div>
<p className="text-sm text-muted-foreground font-mono">
{pipeline.slug}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Link href={`/admin/rounds/pipeline/${pipelineId}/edit` as Route}>
<Button variant="outline" size="sm">
<Edit className="h-4 w-4 mr-1" />
Edit
</Button>
</Link>
<Link href={`/admin/rounds/pipeline/${pipelineId}/advanced` as Route}>
<Button variant="outline" size="sm">
<Settings2 className="h-4 w-4 mr-1" />
@@ -228,9 +287,7 @@ export default function PipelineDetailPage() {
{pipeline.status === 'DRAFT' && (
<DropdownMenuItem
disabled={publishMutation.isPending}
onClick={() =>
publishMutation.mutate({ id: pipelineId })
}
onClick={() => publishMutation.mutate({ id: pipelineId })}
>
{publishMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -243,12 +300,7 @@ export default function PipelineDetailPage() {
{pipeline.status === 'ACTIVE' && (
<DropdownMenuItem
disabled={updateMutation.isPending}
onClick={() =>
updateMutation.mutate({
id: pipelineId,
status: 'CLOSED',
})
}
onClick={() => handleStatusChange('CLOSED')}
>
Close Pipeline
</DropdownMenuItem>
@@ -256,12 +308,7 @@ export default function PipelineDetailPage() {
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={updateMutation.isPending}
onClick={() =>
updateMutation.mutate({
id: pipelineId,
status: 'ARCHIVED',
})
}
onClick={() => handleStatusChange('ARCHIVED')}
>
<Archive className="h-4 w-4 mr-2" />
Archive
@@ -320,120 +367,91 @@ export default function PipelineDetailPage() {
</Card>
</div>
{/* Track Tabs */}
{pipeline.tracks.length > 0 && (
<Tabs
value={selectedTrackId ?? undefined}
onValueChange={handleTrackChange}
>
<TabsList className="w-full justify-start overflow-x-auto">
{pipeline.tracks
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((track) => (
<TabsTrigger
key={track.id}
value={track.id}
className="flex items-center gap-1.5"
>
<span>{track.name}</span>
<Badge
variant="outline"
className="text-[9px] h-4 px-1"
>
{track.kind}
</Badge>
</TabsTrigger>
))}
</TabsList>
{pipeline.tracks.map((track) => (
<TabsContent key={track.id} value={track.id} className="mt-4">
{/* Track Info */}
<Card className="mb-4">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-sm">{track.name}</CardTitle>
<CardDescription className="font-mono text-xs">
{track.slug}
</CardDescription>
</div>
<div className="flex items-center gap-2">
{track.routingMode && (
<Badge variant="outline" className="text-[10px]">
{track.routingMode}
</Badge>
)}
{track.decisionMode && (
<Badge variant="outline" className="text-[10px]">
{track.decisionMode}
</Badge>
)}
</div>
</div>
</CardHeader>
</Card>
{/* Stage Tabs within Track */}
{track.stages.length > 0 ? (
<Tabs
value={
{/* Track Switcher (only if multiple tracks) */}
{pipeline.tracks.length > 1 && (
<div className="flex items-center gap-2 flex-wrap">
{pipeline.tracks
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((track) => (
<button
key={track.id}
onClick={() => handleTrackChange(track.id)}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors',
selectedTrackId === track.id
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
)}
>
<span>{track.name}</span>
<Badge
variant="outline"
className={cn(
'text-[9px] h-4 px-1',
selectedTrackId === track.id
? selectedStageId ?? undefined
: undefined
}
onValueChange={setSelectedStageId}
? 'border-primary-foreground/20 text-primary-foreground/80'
: ''
)}
>
<TabsList className="w-full justify-start overflow-x-auto">
{track.stages
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => (
<TabsTrigger
key={stage.id}
value={stage.id}
className="flex items-center gap-1.5"
>
<span>{stage.name}</span>
<Badge
variant="secondary"
className={cn(
'text-[9px] h-4 px-1',
stageTypeColors[stage.stageType] ?? ''
)}
>
{stage.stageType.replace('_', ' ')}
</Badge>
</TabsTrigger>
))}
</TabsList>
{track.stages.map((stage) => (
<TabsContent
key={stage.id}
value={stage.id}
className="mt-4"
>
<StagePanel
stageId={stage.id}
stageType={stage.stageType}
configJson={
stage.configJson as Record<string, unknown> | null
}
/>
</TabsContent>
))}
</Tabs>
) : (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No stages configured for this track
</CardContent>
</Card>
)}
</TabsContent>
))}
</Tabs>
{track.kind}
</Badge>
</button>
))}
</div>
)}
{/* Pipeline Flowchart */}
{flowchartTracks.length > 0 ? (
<PipelineFlowchart
tracks={flowchartTracks}
selectedStageId={selectedStageId}
onStageSelect={handleStageSelect}
/>
) : (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No tracks configured for this pipeline
</CardContent>
</Card>
)}
{/* Selected Stage Detail */}
<div ref={stagePanelRef}>
{selectedStage ? (
<div className="space-y-4">
<div className="border-t pt-4">
<h2 className="text-lg font-semibold text-muted-foreground">
Selected Stage: <span className="text-foreground">{selectedStage.name}</span>
</h2>
</div>
{/* Stage Config Editor */}
<StageConfigEditor
stageId={selectedStage.id}
stageName={selectedStage.name}
stageType={selectedStage.stageType}
configJson={selectedStage.configJson as Record<string, unknown> | null}
onSave={updateStageConfig}
isSaving={isUpdating}
/>
{/* Stage Activity Panel */}
<StagePanel
stageId={selectedStage.id}
stageType={selectedStage.stageType}
configJson={selectedStage.configJson as Record<string, unknown> | null}
/>
</div>
) : (
<Card>
<CardContent className="py-12 text-center">
<p className="text-sm text-muted-foreground">
Click a stage in the flowchart above to view its configuration and activity
</p>
</CardContent>
</Card>
)}
</div>
</div>
)
}

View File

@@ -1,7 +1,5 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
@@ -10,39 +8,45 @@ import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Plus,
MoreHorizontal,
Eye,
Edit,
Layers,
GitBranch,
Calendar,
Workflow,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { format } from 'date-fns'
import { formatDistanceToNow } from 'date-fns'
import { useEdition } from '@/contexts/edition-context'
const statusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700',
ACTIVE: 'bg-emerald-100 text-emerald-700',
ARCHIVED: 'bg-muted text-muted-foreground',
CLOSED: 'bg-blue-100 text-blue-700',
}
const statusConfig = {
DRAFT: {
label: 'Draft',
bgClass: 'bg-gray-100 text-gray-700',
dotClass: 'bg-gray-500',
},
ACTIVE: {
label: 'Active',
bgClass: 'bg-emerald-100 text-emerald-700',
dotClass: 'bg-emerald-500',
},
CLOSED: {
label: 'Closed',
bgClass: 'bg-blue-100 text-blue-700',
dotClass: 'bg-blue-500',
},
ARCHIVED: {
label: 'Archived',
bgClass: 'bg-muted text-muted-foreground',
dotClass: 'bg-muted-foreground',
},
} as const
export default function PipelineListPage() {
const router = useRouter()
const { currentEdition } = useEdition()
const programId = currentEdition?.id
@@ -51,13 +55,6 @@ export default function PipelineListPage() {
{ enabled: !!programId }
)
// Auto-redirect when there's exactly one pipeline
useEffect(() => {
if (!isLoading && pipelines && pipelines.length === 1) {
router.replace(`/admin/rounds/pipeline/${pipelines[0].id}` as Route)
}
}, [isLoading, pipelines, router])
if (!programId) {
return (
<div className="space-y-6">
@@ -122,17 +119,20 @@ export default function PipelineListPage() {
{/* Empty State */}
{!isLoading && (!pipelines || pipelines.length === 0) && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<GitBranch className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Pipelines Yet</p>
<p className="text-sm text-muted-foreground mb-4">
Create your first pipeline to start managing project evaluation
<Card className="border-2 border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-full bg-primary/10 p-4 mb-4">
<Workflow className="h-10 w-10 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2">No Pipelines Yet</h3>
<p className="text-sm text-muted-foreground max-w-md mb-6">
Pipelines organize your project evaluation workflow into tracks and stages.
Create your first pipeline to get started with managing project evaluations.
</p>
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
<Button size="sm">
<Plus className="h-4 w-4 mr-1" />
Create Pipeline
<Button>
<Plus className="h-4 w-4 mr-2" />
Create Your First Pipeline
</Button>
</Link>
</CardContent>
@@ -142,80 +142,101 @@ export default function PipelineListPage() {
{/* Pipeline Cards */}
{pipelines && pipelines.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{pipelines.map((pipeline) => (
<Link
key={pipeline.id}
href={`/admin/rounds/pipeline/${pipeline.id}` as Route}
className="block"
>
<Card className="group hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="text-base truncate">
{pipeline.name}
</CardTitle>
<CardDescription className="font-mono text-xs">
{pipeline.slug}
</CardDescription>
</div>
<div className="flex items-center gap-2">
{pipelines.map((pipeline) => {
const status = pipeline.status as keyof typeof statusConfig
const config = statusConfig[status] || statusConfig.DRAFT
const description = (pipeline.settingsJson as Record<string, unknown> | null)?.description as string | undefined
return (
<Link
key={pipeline.id}
href={`/admin/rounds/pipeline/${pipeline.id}` as Route}
className="block"
>
<Card className="group hover:shadow-md transition-shadow cursor-pointer h-full flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<CardTitle className="text-base leading-tight mb-1">
{pipeline.name}
</CardTitle>
<p className="font-mono text-xs text-muted-foreground truncate">
{pipeline.slug}
</p>
</div>
<Badge
variant="secondary"
className={cn(
'text-[10px] shrink-0',
statusColors[pipeline.status] ?? ''
'text-[10px] shrink-0 flex items-center gap-1.5',
config.bgClass
)}
>
{pipeline.status}
<span className={cn('h-1.5 w-1.5 rounded-full', config.dotClass)} />
{config.label}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.preventDefault()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/pipeline/${pipeline.id}` as Route}>
<Eye className="h-4 w-4 mr-2" />
View
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route}>
<Edit className="h-4 w-4 mr-2" />
Edit
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Layers className="h-3.5 w-3.5" />
<span>{pipeline._count.tracks} tracks</span>
{/* Description */}
{description && (
<p className="text-xs text-muted-foreground line-clamp-2 mt-2">
{description}
</p>
)}
</CardHeader>
<CardContent className="mt-auto">
{/* Track Indicator - Simplified visualization */}
<div className="mb-3 pb-3 border-b">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1.5">
<Layers className="h-3.5 w-3.5" />
<span className="font-medium">
{pipeline._count.tracks === 0
? 'No tracks'
: pipeline._count.tracks === 1
? '1 track'
: `${pipeline._count.tracks} tracks`}
</span>
</div>
{pipeline._count.tracks > 0 && (
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(pipeline._count.tracks, 5) }).map((_, i) => (
<div
key={i}
className="h-6 flex-1 rounded border border-border bg-muted/30 flex items-center justify-center"
>
<div className="h-1 w-1 rounded-full bg-muted-foreground/40" />
</div>
))}
{pipeline._count.tracks > 5 && (
<span className="text-[10px] text-muted-foreground ml-1">
+{pipeline._count.tracks - 5}
</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-1">
<GitBranch className="h-3.5 w-3.5" />
<span>{pipeline._count.routingRules} rules</span>
{/* Stats */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<GitBranch className="h-3.5 w-3.5" />
<span>Routing rules</span>
</div>
<span className="font-medium text-foreground">
{pipeline._count.routingRules}
</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Updated {formatDistanceToNow(new Date(pipeline.updatedAt))} ago</span>
</div>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
Created {format(new Date(pipeline.createdAt), 'MMM d, yyyy')}
</p>
</CardContent>
</Card>
</Link>
))}
</CardContent>
</Card>
</Link>
)
})}
</div>
)}
</div>