Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s

This commit is contained in:
Matt
2026-02-14 15:26:42 +01:00
parent e56e143a40
commit b5425e705e
374 changed files with 116737 additions and 111969 deletions

View File

@@ -1,352 +1,352 @@
'use client'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { ArrowLeft } from 'lucide-react'
import Link from 'next/link'
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'
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 { useEdition } from '@/contexts/edition-context'
import { defaultWizardState, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
export default function NewPipelinePage() {
const router = useRouter()
const searchParams = useSearchParams()
const { currentEdition } = useEdition()
const programId = searchParams.get('programId') || currentEdition?.id || ''
const [state, setState] = useState<WizardState>(() => defaultWizardState(programId))
const [currentStep, setCurrentStep] = useState(0)
const initialStateRef = useRef(JSON.stringify(state))
// Update programId in state when edition context loads
useEffect(() => {
if (programId && !state.programId) {
setState((prev) => ({ ...prev, programId }))
}
}, [programId, state.programId])
// Dirty tracking — warn on navigate away
useEffect(() => {
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, ...updates }))
}, [])
// Get stage configs from the main track
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 updateStageConfig = useCallback(
(stageType: string, configJson: Record<string, unknown>) => {
setState((prev) => ({
...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) => ({
...prev,
tracks: prev.tracks.map((track) =>
track.kind === 'MAIN' ? { ...track, stages } : track
),
}))
},
[]
)
// Validation
const basicsValid = validateBasics(state).valid
const tracksValid = validateTracks(state.tracks).valid
const allValid = validateAll(state).valid
// Mutations
const createMutation = trpc.pipeline.createStructure.useMutation({
onSuccess: (data) => {
initialStateRef.current = JSON.stringify(state) // prevent dirty warning
toast.success('Pipeline created successfully')
router.push(`/admin/rounds/pipeline/${data.pipeline.id}` as Route)
},
onError: (err) => {
toast.error(err.message)
},
})
const publishMutation = trpc.pipeline.publish.useMutation({
onSuccess: () => {
toast.success('Pipeline published successfully')
},
onError: (err) => {
toast.error(err.message)
},
})
const handleSave = async (publish: boolean) => {
const validation = validateAll(state)
if (!validation.valid) {
toast.error('Please fix validation errors before saving')
// Navigate to first section with errors
if (!validation.sections.basics.valid) setCurrentStep(0)
else if (!validation.sections.tracks.valid) setCurrentStep(2)
return
}
const result = await createMutation.mutateAsync({
programId: state.programId,
name: state.name,
slug: state.slug,
settingsJson: {
...state.settingsJson,
notificationConfig: state.notificationConfig,
overridePolicy: state.overridePolicy,
},
tracks: state.tracks.map((t) => ({
name: t.name,
slug: t.slug,
kind: t.kind,
sortOrder: t.sortOrder,
routingModeDefault: t.routingModeDefault,
decisionMode: t.decisionMode,
stages: t.stages.map((s) => ({
name: s.name,
slug: s.slug,
stageType: s.stageType,
sortOrder: s.sortOrder,
configJson: s.configJson,
})),
awardConfig: t.awardConfig,
})),
autoTransitions: true,
})
if (publish && result.pipeline.id) {
await publishMutation.mutateAsync({ id: result.pipeline.id })
}
}
const isSaving = createMutation.isPending && !publishMutation.isPending
const isSubmitting = publishMutation.isPending
if (!programId) {
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">Create Pipeline</h1>
<p className="text-sm text-muted-foreground">
Please select an edition first to create a pipeline.
</p>
</div>
</div>
</div>
)
}
// Step configuration
const steps: StepConfig[] = [
{
title: 'Basics',
description: 'Pipeline name and program',
isValid: basicsValid,
},
{
title: 'Intake',
description: 'Submission window & files',
isValid: !!intakeStage,
},
{
title: 'Main Track Stages',
description: 'Configure pipeline stages',
isValid: tracksValid,
},
{
title: 'Screening',
description: 'Gate rules and AI screening',
isValid: !!filterStage,
},
{
title: 'Evaluation',
description: 'Jury assignment strategy',
isValid: !!evalStage,
},
{
title: 'Awards',
description: 'Special award tracks',
isValid: true, // Awards are optional
},
{
title: 'Live Finals',
description: 'Voting and reveal settings',
isValid: !!liveStage,
},
{
title: 'Notifications',
description: 'Event notifications',
isValid: true, // Always valid
},
{
title: 'Review & Create',
description: 'Validation summary',
isValid: allValid,
},
]
return (
<div className="space-y-6 pb-8">
{/* Header */}
<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>
{/* 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} />
</div>
{/* Step 1: Intake */}
<div>
<IntakeSection
config={intakeConfig}
onChange={(c) =>
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 2: Main Track Stages */}
<div>
<MainTrackSection
stages={mainTrack?.stages ?? []}
onChange={updateMainTrackStages}
/>
</div>
{/* Step 3: Screening */}
<div>
<FilteringSection
config={filterConfig}
onChange={(c) =>
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 4: Evaluation */}
<div>
<AssignmentSection
config={evalConfig}
onChange={(c) =>
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 5: Awards */}
<div>
<AwardsSection
tracks={state.tracks}
onChange={(tracks) => updateState({ tracks })}
/>
</div>
{/* Step 6: Live Finals */}
<div>
<LiveFinalsSection
config={liveConfig}
onChange={(c) =>
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 7: Notifications */}
<div>
<NotificationsSection
config={state.notificationConfig}
onChange={(notificationConfig) => updateState({ notificationConfig })}
overridePolicy={state.overridePolicy}
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
/>
</div>
{/* Step 8: Review & Create */}
<div>
<ReviewSection state={state} />
</div>
</SidebarStepper>
</div>
)
}
'use client'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { ArrowLeft } from 'lucide-react'
import Link from 'next/link'
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'
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 { useEdition } from '@/contexts/edition-context'
import { defaultWizardState, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
export default function NewPipelinePage() {
const router = useRouter()
const searchParams = useSearchParams()
const { currentEdition } = useEdition()
const programId = searchParams.get('programId') || currentEdition?.id || ''
const [state, setState] = useState<WizardState>(() => defaultWizardState(programId))
const [currentStep, setCurrentStep] = useState(0)
const initialStateRef = useRef(JSON.stringify(state))
// Update programId in state when edition context loads
useEffect(() => {
if (programId && !state.programId) {
setState((prev) => ({ ...prev, programId }))
}
}, [programId, state.programId])
// Dirty tracking — warn on navigate away
useEffect(() => {
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, ...updates }))
}, [])
// Get stage configs from the main track
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 updateStageConfig = useCallback(
(stageType: string, configJson: Record<string, unknown>) => {
setState((prev) => ({
...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) => ({
...prev,
tracks: prev.tracks.map((track) =>
track.kind === 'MAIN' ? { ...track, stages } : track
),
}))
},
[]
)
// Validation
const basicsValid = validateBasics(state).valid
const tracksValid = validateTracks(state.tracks).valid
const allValid = validateAll(state).valid
// Mutations
const createMutation = trpc.pipeline.createStructure.useMutation({
onSuccess: (data) => {
initialStateRef.current = JSON.stringify(state) // prevent dirty warning
toast.success('Pipeline created successfully')
router.push(`/admin/rounds/pipeline/${data.pipeline.id}` as Route)
},
onError: (err) => {
toast.error(err.message)
},
})
const publishMutation = trpc.pipeline.publish.useMutation({
onSuccess: () => {
toast.success('Pipeline published successfully')
},
onError: (err) => {
toast.error(err.message)
},
})
const handleSave = async (publish: boolean) => {
const validation = validateAll(state)
if (!validation.valid) {
toast.error('Please fix validation errors before saving')
// Navigate to first section with errors
if (!validation.sections.basics.valid) setCurrentStep(0)
else if (!validation.sections.tracks.valid) setCurrentStep(2)
return
}
const result = await createMutation.mutateAsync({
programId: state.programId,
name: state.name,
slug: state.slug,
settingsJson: {
...state.settingsJson,
notificationConfig: state.notificationConfig,
overridePolicy: state.overridePolicy,
},
tracks: state.tracks.map((t) => ({
name: t.name,
slug: t.slug,
kind: t.kind,
sortOrder: t.sortOrder,
routingModeDefault: t.routingModeDefault,
decisionMode: t.decisionMode,
stages: t.stages.map((s) => ({
name: s.name,
slug: s.slug,
stageType: s.stageType,
sortOrder: s.sortOrder,
configJson: s.configJson,
})),
awardConfig: t.awardConfig,
})),
autoTransitions: true,
})
if (publish && result.pipeline.id) {
await publishMutation.mutateAsync({ id: result.pipeline.id })
}
}
const isSaving = createMutation.isPending && !publishMutation.isPending
const isSubmitting = publishMutation.isPending
if (!programId) {
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">Create Pipeline</h1>
<p className="text-sm text-muted-foreground">
Please select an edition first to create a pipeline.
</p>
</div>
</div>
</div>
)
}
// Step configuration
const steps: StepConfig[] = [
{
title: 'Basics',
description: 'Pipeline name and program',
isValid: basicsValid,
},
{
title: 'Intake',
description: 'Submission window & files',
isValid: !!intakeStage,
},
{
title: 'Main Track Stages',
description: 'Configure pipeline stages',
isValid: tracksValid,
},
{
title: 'Screening',
description: 'Gate rules and AI screening',
isValid: !!filterStage,
},
{
title: 'Evaluation',
description: 'Jury assignment strategy',
isValid: !!evalStage,
},
{
title: 'Awards',
description: 'Special award tracks',
isValid: true, // Awards are optional
},
{
title: 'Live Finals',
description: 'Voting and reveal settings',
isValid: !!liveStage,
},
{
title: 'Notifications',
description: 'Event notifications',
isValid: true, // Always valid
},
{
title: 'Review & Create',
description: 'Validation summary',
isValid: allValid,
},
]
return (
<div className="space-y-6 pb-8">
{/* Header */}
<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>
{/* 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} />
</div>
{/* Step 1: Intake */}
<div>
<IntakeSection
config={intakeConfig}
onChange={(c) =>
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 2: Main Track Stages */}
<div>
<MainTrackSection
stages={mainTrack?.stages ?? []}
onChange={updateMainTrackStages}
/>
</div>
{/* Step 3: Screening */}
<div>
<FilteringSection
config={filterConfig}
onChange={(c) =>
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 4: Evaluation */}
<div>
<AssignmentSection
config={evalConfig}
onChange={(c) =>
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 5: Awards */}
<div>
<AwardsSection
tracks={state.tracks}
onChange={(tracks) => updateState({ tracks })}
/>
</div>
{/* Step 6: Live Finals */}
<div>
<LiveFinalsSection
config={liveConfig}
onChange={(c) =>
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 7: Notifications */}
<div>
<NotificationsSection
config={state.notificationConfig}
onChange={(notificationConfig) => updateState({ notificationConfig })}
overridePolicy={state.overridePolicy}
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
/>
</div>
{/* Step 8: Review & Create */}
<div>
<ReviewSection state={state} />
</div>
</SidebarStepper>
</div>
)
}

View File

@@ -1,554 +1,12 @@
'use client'
import { redirect } from 'next/navigation'
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 AdvancedPipelinePageProps = {
params: Promise<{ id: string }>
}
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>
)
export default async function AdvancedPipelinePage({
params,
}: AdvancedPipelinePageProps) {
const { id } = await params
redirect(`/admin/rounds/pipeline/${id}` as never)
}

View File

@@ -1,11 +1,11 @@
import { redirect } from 'next/navigation'
type EditPipelinePageProps = {
params: Promise<{ id: string }>
}
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)
}
import { redirect } from 'next/navigation'
type EditPipelinePageProps = {
params: Promise<{ id: string }>
}
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)
}

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,6 @@ import {
Calendar,
Workflow,
Pencil,
Settings2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
@@ -233,18 +232,12 @@ export default function PipelineListPage() {
</div>
<div className="mt-3 flex items-center gap-2">
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route} className="flex-1">
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route} className="w-full">
<Button size="sm" variant="outline" className="w-full">
<Pencil className="h-3.5 w-3.5 mr-1.5" />
Edit
</Button>
</Link>
<Link href={`/admin/rounds/pipeline/${pipeline.id}/advanced` as Route} className="flex-1">
<Button size="sm" variant="outline" className="w-full">
<Settings2 className="h-3.5 w-3.5 mr-1.5" />
Advanced
</Button>
</Link>
</div>
</CardContent>
</Card>