Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user