391 lines
13 KiB
TypeScript
391 lines
13 KiB
TypeScript
|
|
'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, Loader2, Save, Rocket } from 'lucide-react'
|
||
|
|
import Link from 'next/link'
|
||
|
|
|
||
|
|
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 { 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 programId = searchParams.get('programId') ?? ''
|
||
|
|
|
||
|
|
const [state, setState] = useState<WizardState>(() => defaultWizardState(programId))
|
||
|
|
const [openSection, setOpenSection] = useState(0)
|
||
|
|
const initialStateRef = useRef(JSON.stringify(state))
|
||
|
|
|
||
|
|
// 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')
|
||
|
|
// Open first section with errors
|
||
|
|
if (!validation.sections.basics.valid) setOpenSection(0)
|
||
|
|
else if (!validation.sections.tracks.valid) setOpenSection(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 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, // Awards are optional
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: 'Live Finals',
|
||
|
|
description: 'Voting, cohorts, and reveal settings',
|
||
|
|
isValid: !!liveStage,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: 'Notifications',
|
||
|
|
description: 'Event notifications and override governance',
|
||
|
|
isValid: true, // Always valid
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: 'Review & Publish',
|
||
|
|
description: 'Validation summary and publish controls',
|
||
|
|
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/pipelines">
|
||
|
|
<Button variant="ghost" size="icon">
|
||
|
|
<ArrowLeft className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</Link>
|
||
|
|
<div>
|
||
|
|
<h1 className="text-xl font-bold">Create Pipeline</h1>
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
Configure the full pipeline structure for project evaluation
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="outline"
|
||
|
|
disabled={isSaving || !allValid}
|
||
|
|
onClick={() => handleSave(false)}
|
||
|
|
>
|
||
|
|
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
||
|
|
Save Draft
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
disabled={isSaving || !allValid}
|
||
|
|
onClick={() => handleSave(true)}
|
||
|
|
>
|
||
|
|
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Rocket className="h-4 w-4 mr-2" />}
|
||
|
|
Save & Publish
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Wizard Sections */}
|
||
|
|
<div className="space-y-3">
|
||
|
|
{/* 0: Basics */}
|
||
|
|
<WizardSection
|
||
|
|
stepNumber={1}
|
||
|
|
title={sections[0].title}
|
||
|
|
description={sections[0].description}
|
||
|
|
isOpen={openSection === 0}
|
||
|
|
onToggle={() => setOpenSection(openSection === 0 ? -1 : 0)}
|
||
|
|
isValid={sections[0].isValid}
|
||
|
|
>
|
||
|
|
<BasicsSection state={state} onChange={updateState} />
|
||
|
|
</WizardSection>
|
||
|
|
|
||
|
|
{/* 1: Intake */}
|
||
|
|
<WizardSection
|
||
|
|
stepNumber={2}
|
||
|
|
title={sections[1].title}
|
||
|
|
description={sections[1].description}
|
||
|
|
isOpen={openSection === 1}
|
||
|
|
onToggle={() => setOpenSection(openSection === 1 ? -1 : 1)}
|
||
|
|
isValid={sections[1].isValid}
|
||
|
|
>
|
||
|
|
<IntakeSection
|
||
|
|
config={intakeConfig}
|
||
|
|
onChange={(c) =>
|
||
|
|
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</WizardSection>
|
||
|
|
|
||
|
|
{/* 2: Main Track Stages */}
|
||
|
|
<WizardSection
|
||
|
|
stepNumber={3}
|
||
|
|
title={sections[2].title}
|
||
|
|
description={sections[2].description}
|
||
|
|
isOpen={openSection === 2}
|
||
|
|
onToggle={() => setOpenSection(openSection === 2 ? -1 : 2)}
|
||
|
|
isValid={sections[2].isValid}
|
||
|
|
>
|
||
|
|
<MainTrackSection
|
||
|
|
stages={mainTrack?.stages ?? []}
|
||
|
|
onChange={updateMainTrackStages}
|
||
|
|
/>
|
||
|
|
</WizardSection>
|
||
|
|
|
||
|
|
{/* 3: Filtering */}
|
||
|
|
<WizardSection
|
||
|
|
stepNumber={4}
|
||
|
|
title={sections[3].title}
|
||
|
|
description={sections[3].description}
|
||
|
|
isOpen={openSection === 3}
|
||
|
|
onToggle={() => setOpenSection(openSection === 3 ? -1 : 3)}
|
||
|
|
isValid={sections[3].isValid}
|
||
|
|
>
|
||
|
|
<FilteringSection
|
||
|
|
config={filterConfig}
|
||
|
|
onChange={(c) =>
|
||
|
|
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</WizardSection>
|
||
|
|
|
||
|
|
{/* 4: Assignment */}
|
||
|
|
<WizardSection
|
||
|
|
stepNumber={5}
|
||
|
|
title={sections[4].title}
|
||
|
|
description={sections[4].description}
|
||
|
|
isOpen={openSection === 4}
|
||
|
|
onToggle={() => setOpenSection(openSection === 4 ? -1 : 4)}
|
||
|
|
isValid={sections[4].isValid}
|
||
|
|
>
|
||
|
|
<AssignmentSection
|
||
|
|
config={evalConfig}
|
||
|
|
onChange={(c) =>
|
||
|
|
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</WizardSection>
|
||
|
|
|
||
|
|
{/* 5: Awards */}
|
||
|
|
<WizardSection
|
||
|
|
stepNumber={6}
|
||
|
|
title={sections[5].title}
|
||
|
|
description={sections[5].description}
|
||
|
|
isOpen={openSection === 5}
|
||
|
|
onToggle={() => setOpenSection(openSection === 5 ? -1 : 5)}
|
||
|
|
isValid={sections[5].isValid}
|
||
|
|
>
|
||
|
|
<AwardsSection tracks={state.tracks} onChange={(tracks) => updateState({ tracks })} />
|
||
|
|
</WizardSection>
|
||
|
|
|
||
|
|
{/* 6: Live Finals */}
|
||
|
|
<WizardSection
|
||
|
|
stepNumber={7}
|
||
|
|
title={sections[6].title}
|
||
|
|
description={sections[6].description}
|
||
|
|
isOpen={openSection === 6}
|
||
|
|
onToggle={() => setOpenSection(openSection === 6 ? -1 : 6)}
|
||
|
|
isValid={sections[6].isValid}
|
||
|
|
>
|
||
|
|
<LiveFinalsSection
|
||
|
|
config={liveConfig}
|
||
|
|
onChange={(c) =>
|
||
|
|
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</WizardSection>
|
||
|
|
|
||
|
|
{/* 7: Notifications */}
|
||
|
|
<WizardSection
|
||
|
|
stepNumber={8}
|
||
|
|
title={sections[7].title}
|
||
|
|
description={sections[7].description}
|
||
|
|
isOpen={openSection === 7}
|
||
|
|
onToggle={() => setOpenSection(openSection === 7 ? -1 : 7)}
|
||
|
|
isValid={sections[7].isValid}
|
||
|
|
>
|
||
|
|
<NotificationsSection
|
||
|
|
config={state.notificationConfig}
|
||
|
|
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
||
|
|
overridePolicy={state.overridePolicy}
|
||
|
|
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
|
||
|
|
/>
|
||
|
|
</WizardSection>
|
||
|
|
|
||
|
|
{/* 8: Review */}
|
||
|
|
<WizardSection
|
||
|
|
stepNumber={9}
|
||
|
|
title={sections[8].title}
|
||
|
|
description={sections[8].description}
|
||
|
|
isOpen={openSection === 8}
|
||
|
|
onToggle={() => setOpenSection(openSection === 8 ? -1 : 8)}
|
||
|
|
isValid={sections[8].isValid}
|
||
|
|
>
|
||
|
|
<ReviewSection state={state} />
|
||
|
|
</WizardSection>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|