Round system redesign: Phases 1-7 complete

Full pipeline/track/stage architecture replacing the legacy round system.

Schema: 11 new models (Pipeline, Track, Stage, StageTransition,
ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor,
OverrideAction, AudienceVoter) + 8 new enums.

Backend: 9 new routers (pipeline, stage, routing, stageFiltering,
stageAssignment, cohort, live, decision, award) + 6 new services
(stage-engine, routing-engine, stage-filtering, stage-assignment,
stage-notifications, live-control).

Frontend: Pipeline wizard (17 components), jury stage pages (7),
applicant pipeline pages (3), public stage pages (2), admin pipeline
pages (5), shared stage components (3), SSE route, live hook.

Phase 6 refit: 23 routers/services migrated from roundId to stageId,
all frontend components refitted. Deleted round.ts (985 lines),
roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx,
10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs.

Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing,
TypeScript 0 errors, Next.js build succeeds, 13 integrity checks,
legacy symbol sweep clean, auto-seed on first Docker startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 13:57:09 +01:00
parent 8a328357e3
commit 331b67dae0
256 changed files with 29117 additions and 21424 deletions

View File

@@ -0,0 +1,554 @@
'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route as NextRoute } from 'next'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { cn } from '@/lib/utils'
import {
ArrowLeft,
Save,
Loader2,
ChevronRight,
Layers,
GitBranch,
Route,
Play,
} from 'lucide-react'
import { PipelineVisualization } from '@/components/admin/pipeline/pipeline-visualization'
const stageTypeColors: Record<string, string> = {
INTAKE: 'text-blue-600',
FILTER: 'text-amber-600',
EVALUATION: 'text-purple-600',
SELECTION: 'text-rose-600',
LIVE_FINAL: 'text-emerald-600',
RESULTS: 'text-cyan-600',
}
type SelectedItem =
| { type: 'stage'; trackId: string; stageId: string }
| { type: 'track'; trackId: string }
| null
export default function AdvancedEditorPage() {
const params = useParams()
const pipelineId = params.id as string
const [selectedItem, setSelectedItem] = useState<SelectedItem>(null)
const [configEditValue, setConfigEditValue] = useState('')
const [simulationProjectIds, setSimulationProjectIds] = useState('')
const [showSaveConfirm, setShowSaveConfirm] = useState(false)
const { data: pipeline, isLoading, refetch } = trpc.pipeline.getDraft.useQuery({
id: pipelineId,
})
const updateConfigMutation = trpc.stage.updateConfig.useMutation({
onSuccess: () => {
toast.success('Stage config saved')
refetch()
},
onError: (err) => toast.error(err.message),
})
const simulateMutation = trpc.pipeline.simulate.useMutation({
onSuccess: (data) => {
toast.success(`Simulation complete: ${data.simulations?.length ?? 0} results`)
},
onError: (err) => toast.error(err.message),
})
const { data: routingRules } = trpc.routing.listRules.useQuery(
{ pipelineId },
{ enabled: !!pipelineId }
)
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8" />
<Skeleton className="h-6 w-48" />
</div>
<div className="grid grid-cols-12 gap-4">
<Skeleton className="col-span-3 h-96" />
<Skeleton className="col-span-5 h-96" />
<Skeleton className="col-span-4 h-96" />
</div>
</div>
)
}
if (!pipeline) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href={'/admin/rounds/pipelines' as NextRoute}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-xl font-bold">Pipeline Not Found</h1>
</div>
</div>
)
}
const handleSelectStage = (trackId: string, stageId: string) => {
setSelectedItem({ type: 'stage', trackId, stageId })
const track = pipeline.tracks.find((t) => t.id === trackId)
const stage = track?.stages.find((s) => s.id === stageId)
setConfigEditValue(
JSON.stringify(stage?.configJson ?? {}, null, 2)
)
}
const executeSaveConfig = () => {
if (selectedItem?.type !== 'stage') return
try {
const parsed = JSON.parse(configEditValue)
updateConfigMutation.mutate({
id: selectedItem.stageId,
configJson: parsed,
})
} catch {
toast.error('Invalid JSON in config editor')
}
}
const handleSaveConfig = () => {
if (selectedItem?.type !== 'stage') return
// Validate JSON first
try {
JSON.parse(configEditValue)
} catch {
toast.error('Invalid JSON in config editor')
return
}
// If pipeline is active or stage has projects, require confirmation
const stage = pipeline?.tracks
.flatMap((t) => t.stages)
.find((s) => s.id === selectedItem.stageId)
const hasProjects = (stage?._count?.projectStageStates ?? 0) > 0
const isActive = pipeline?.status === 'ACTIVE'
if (isActive || hasProjects) {
setShowSaveConfirm(true)
} else {
executeSaveConfig()
}
}
const handleSimulate = () => {
const ids = simulationProjectIds
.split(',')
.map((s) => s.trim())
.filter(Boolean)
if (ids.length === 0) {
toast.error('Enter at least one project ID')
return
}
simulateMutation.mutate({ id: pipelineId, projectIds: ids })
}
const selectedTrack =
selectedItem?.type === 'stage'
? pipeline.tracks.find((t) => t.id === selectedItem.trackId)
: selectedItem?.type === 'track'
? pipeline.tracks.find((t) => t.id === selectedItem.trackId)
: null
const selectedStage =
selectedItem?.type === 'stage'
? selectedTrack?.stages.find((s) => s.id === selectedItem.stageId)
: null
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href={`/admin/rounds/pipeline/${pipelineId}` as NextRoute}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Advanced Editor</h1>
<p className="text-sm text-muted-foreground">{pipeline.name}</p>
</div>
</div>
</div>
{/* Visualization */}
<PipelineVisualization tracks={pipeline.tracks} />
{/* Five Panel Layout */}
<div className="grid grid-cols-12 gap-4">
{/* Panel 1 — Track/Stage Tree (left sidebar) */}
<div className="col-span-12 lg:col-span-3">
<Card className="h-full">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Layers className="h-4 w-4" />
Structure
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 max-h-[600px] overflow-y-auto">
{pipeline.tracks
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((track) => (
<div key={track.id}>
<button
type="button"
className={cn(
'w-full text-left px-2 py-1.5 rounded text-sm font-medium hover:bg-muted transition-colors',
selectedItem?.type === 'track' &&
selectedItem.trackId === track.id
? 'bg-muted'
: ''
)}
onClick={() =>
setSelectedItem({ type: 'track', trackId: track.id })
}
>
<div className="flex items-center gap-1.5">
<ChevronRight className="h-3 w-3" />
<span>{track.name}</span>
<Badge variant="outline" className="text-[9px] h-4 px-1 ml-auto">
{track.kind}
</Badge>
</div>
</button>
<div className="ml-4 space-y-0.5 mt-0.5">
{track.stages
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => (
<button
key={stage.id}
type="button"
className={cn(
'w-full text-left px-2 py-1 rounded text-xs hover:bg-muted transition-colors',
selectedItem?.type === 'stage' &&
selectedItem.stageId === stage.id
? 'bg-muted font-medium'
: ''
)}
onClick={() =>
handleSelectStage(track.id, stage.id)
}
>
<div className="flex items-center gap-1.5">
<span
className={cn(
'text-[10px] font-mono',
stageTypeColors[stage.stageType] ?? ''
)}
>
{stage.stageType.slice(0, 3)}
</span>
<span className="truncate">{stage.name}</span>
{stage._count?.projectStageStates > 0 && (
<Badge
variant="secondary"
className="text-[8px] h-3.5 px-1 ml-auto"
>
{stage._count.projectStageStates}
</Badge>
)}
</div>
</button>
))}
</div>
</div>
))}
</CardContent>
</Card>
</div>
{/* Panel 2 — Stage Config Editor (center) */}
<div className="col-span-12 lg:col-span-5">
<Card className="h-full">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm">
{selectedStage
? `${selectedStage.name} Config`
: selectedTrack
? `${selectedTrack.name} Track`
: 'Select a stage'}
</CardTitle>
{selectedStage && (
<Button
size="sm"
variant="outline"
disabled={updateConfigMutation.isPending}
onClick={handleSaveConfig}
>
{updateConfigMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Save className="h-3.5 w-3.5 mr-1" />
)}
Save
</Button>
)}
</div>
</CardHeader>
<CardContent>
{selectedStage ? (
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary" className="text-[10px]">
{selectedStage.stageType}
</Badge>
<span className="font-mono">{selectedStage.slug}</span>
</div>
<Textarea
value={configEditValue}
onChange={(e) => setConfigEditValue(e.target.value)}
className="font-mono text-xs min-h-[400px]"
placeholder="{ }"
/>
</div>
) : selectedTrack ? (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Kind</span>
<Badge variant="outline" className="text-xs">
{selectedTrack.kind}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Routing Mode</span>
<span className="text-xs font-mono">
{selectedTrack.routingMode ?? 'N/A'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Decision Mode</span>
<span className="text-xs font-mono">
{selectedTrack.decisionMode ?? 'N/A'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Stages</span>
<span className="font-medium">
{selectedTrack.stages.length}
</span>
</div>
{selectedTrack.specialAward && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs font-medium mb-1">Special Award</p>
<p className="text-xs text-muted-foreground">
{selectedTrack.specialAward.name} {' '}
{selectedTrack.specialAward.scoringMode}
</p>
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground py-8 text-center">
Select a track or stage from the tree to view or edit its
configuration
</p>
)}
</CardContent>
</Card>
</div>
{/* Panel 3+4+5 — Routing + Transitions + Simulation (right sidebar) */}
<div className="col-span-12 lg:col-span-4 space-y-4">
{/* Routing Rules */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Route className="h-4 w-4" />
Routing Rules
</CardTitle>
</CardHeader>
<CardContent>
{routingRules && routingRules.length > 0 ? (
<div className="space-y-1 max-h-48 overflow-y-auto">
{routingRules.map((rule) => (
<div
key={rule.id}
className="flex items-center gap-2 text-xs py-1.5 border-b last:border-0"
>
<Badge
variant={rule.isActive ? 'default' : 'secondary'}
className="text-[9px] h-4 shrink-0"
>
P{rule.priority}
</Badge>
<span className="truncate">
{rule.sourceTrack?.name ?? '—'} {' '}
{rule.destinationTrack?.name ?? '—'}
</span>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground py-3 text-center">
No routing rules configured
</p>
)}
</CardContent>
</Card>
{/* Transitions */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<GitBranch className="h-4 w-4" />
Transitions
</CardTitle>
</CardHeader>
<CardContent>
{(() => {
const allTransitions = pipeline.tracks.flatMap((track) =>
track.stages.flatMap((stage) =>
stage.transitionsFrom.map((t) => ({
id: t.id,
fromName: stage.name,
toName: t.toStage?.name ?? '?',
isDefault: t.isDefault,
}))
)
)
return allTransitions.length > 0 ? (
<div className="space-y-1 max-h-48 overflow-y-auto">
{allTransitions.map((t) => (
<div
key={t.id}
className="flex items-center gap-1 text-xs py-1 border-b last:border-0"
>
<span className="truncate">{t.fromName}</span>
<span className="text-muted-foreground"></span>
<span className="truncate">{t.toName}</span>
{t.isDefault && (
<Badge
variant="outline"
className="text-[8px] h-3.5 ml-auto shrink-0"
>
default
</Badge>
)}
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground py-3 text-center">
No transitions defined
</p>
)
})()}
</CardContent>
</Card>
{/* Simulation */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Play className="h-4 w-4" />
Simulation
</CardTitle>
<CardDescription className="text-xs">
Test where projects would route
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-xs">Project IDs (comma-separated)</Label>
<Input
value={simulationProjectIds}
onChange={(e) => setSimulationProjectIds(e.target.value)}
placeholder="id1, id2, id3"
className="text-xs mt-1"
/>
</div>
<Button
size="sm"
className="w-full"
disabled={simulateMutation.isPending || !simulationProjectIds.trim()}
onClick={handleSimulate}
>
{simulateMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Play className="h-3.5 w-3.5 mr-1" />
)}
Run Simulation
</Button>
{simulateMutation.data?.simulations && (
<div className="space-y-1 max-h-32 overflow-y-auto">
{simulateMutation.data.simulations.map((r, i) => (
<div
key={i}
className="text-xs py-1 border-b last:border-0"
>
<span className="font-mono">{r.projectId.slice(0, 8)}</span>
<span className="text-muted-foreground"> </span>
<span>{r.targetTrackName ?? 'unrouted'}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Confirmation dialog for destructive config saves */}
<AlertDialog open={showSaveConfirm} onOpenChange={setShowSaveConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Save Stage Configuration?</AlertDialogTitle>
<AlertDialogDescription>
This stage belongs to an active pipeline or has projects assigned to it.
Changing the configuration may affect ongoing evaluations and project processing.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setShowSaveConfirm(false)
executeSaveConfig()
}}
>
Save Changes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,422 @@
'use client'
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'],
},
}
}
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 = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown 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>)
}
/>
</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}
/>
</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>)
}
/>
</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>)
}
/>
</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 })}
/>
</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>)
}
/>
</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 })
}
/>
</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>
)
}

View File

@@ -0,0 +1,439 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
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,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import {
ArrowLeft,
Edit,
MoreHorizontal,
Rocket,
Archive,
Settings2,
Layers,
GitBranch,
Loader2,
} from 'lucide-react'
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'
import { SelectionPanel } from '@/components/admin/pipeline/stage-panels/selection-panel'
import { LiveFinalPanel } from '@/components/admin/pipeline/stage-panels/live-final-panel'
import { ResultsPanel } from '@/components/admin/pipeline/stage-panels/results-panel'
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 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,
configJson,
}: {
stageId: string
stageType: string
configJson: Record<string, unknown> | null
}) {
switch (stageType) {
case 'INTAKE':
return <IntakePanel stageId={stageId} configJson={configJson} />
case 'FILTER':
return <FilterPanel stageId={stageId} configJson={configJson} />
case 'EVALUATION':
return <EvaluationPanel stageId={stageId} configJson={configJson} />
case 'SELECTION':
return <SelectionPanel stageId={stageId} configJson={configJson} />
case 'LIVE_FINAL':
return <LiveFinalPanel stageId={stageId} configJson={configJson} />
case 'RESULTS':
return <ResultsPanel stageId={stageId} configJson={configJson} />
default:
return (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
Unknown stage type: {stageType}
</CardContent>
</Card>
)
}
}
export default function PipelineDetailPage() {
const params = useParams()
const pipelineId = params.id as string
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
const [selectedStageId, setSelectedStageId] = useState<string | null>(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 publishMutation = trpc.pipeline.publish.useMutation({
onSuccess: () => toast.success('Pipeline published'),
onError: (err) => toast.error(err.message),
})
const updateMutation = trpc.pipeline.update.useMutation({
onSuccess: () => toast.success('Pipeline updated'),
onError: (err) => toast.error(err.message),
})
if (isLoading) {
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>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!pipeline) {
return (
<div className="space-y-6">
<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">Pipeline Not Found</h1>
<p className="text-sm text-muted-foreground">
The requested pipeline does not exist
</p>
</div>
</div>
</div>
)
}
const selectedTrack = pipeline.tracks.find((t) => t.id === selectedTrackId)
const selectedStage = selectedTrack?.stages.find(
(s) => s.id === selectedStageId
)
const handleTrackChange = (trackId: string) => {
setSelectedTrackId(trackId)
const track = pipeline.tracks.find((t) => t.id === trackId)
if (track && track.stages.length > 0) {
setSelectedStageId(track.stages[0].id)
} else {
setSelectedStageId(null)
}
}
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/pipelines">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</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>
</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" />
Advanced
</Button>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{pipeline.status === 'DRAFT' && (
<DropdownMenuItem
disabled={publishMutation.isPending}
onClick={() =>
publishMutation.mutate({ id: pipelineId })
}
>
{publishMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Rocket className="h-4 w-4 mr-2" />
)}
Publish
</DropdownMenuItem>
)}
{pipeline.status === 'ACTIVE' && (
<DropdownMenuItem
disabled={updateMutation.isPending}
onClick={() =>
updateMutation.mutate({
id: pipelineId,
status: 'CLOSED',
})
}
>
Close Pipeline
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={updateMutation.isPending}
onClick={() =>
updateMutation.mutate({
id: pipelineId,
status: 'ARCHIVED',
})
}
>
<Archive className="h-4 w-4 mr-2" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Pipeline Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Tracks</span>
</div>
<p className="text-2xl font-bold mt-1">{pipeline.tracks.length}</p>
<p className="text-xs text-muted-foreground">
{pipeline.tracks.filter((t) => t.kind === 'MAIN').length} main,{' '}
{pipeline.tracks.filter((t) => t.kind === 'AWARD').length} award
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Stages</span>
</div>
<p className="text-2xl font-bold mt-1">
{pipeline.tracks.reduce((sum, t) => sum + t.stages.length, 0)}
</p>
<p className="text-xs text-muted-foreground">across all tracks</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Transitions</span>
</div>
<p className="text-2xl font-bold mt-1">
{pipeline.tracks.reduce(
(sum, t) =>
sum +
t.stages.reduce(
(s, stage) => s + stage.transitionsFrom.length,
0
),
0
)}
</p>
<p className="text-xs text-muted-foreground">stage connections</p>
</CardContent>
</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={
selectedTrackId === track.id
? selectedStageId ?? undefined
: undefined
}
onValueChange={setSelectedStageId}
>
<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>
)}
</div>
)
}