Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,352 +0,0 @@
|
||||
'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,12 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
type AdvancedPipelinePageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function AdvancedPipelinePage({
|
||||
params,
|
||||
}: AdvancedPipelinePageProps) {
|
||||
const { id } = await params
|
||||
redirect(`/admin/rounds/pipeline/${id}` as never)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,675 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
ArrowLeft,
|
||||
MoreHorizontal,
|
||||
Rocket,
|
||||
Archive,
|
||||
Layers,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Save,
|
||||
Wand2,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { InlineEditableText } from '@/components/ui/inline-editable-text'
|
||||
import { PipelineFlowchart } from '@/components/admin/pipeline/pipeline-flowchart'
|
||||
import { StageDetailSheet } from '@/components/admin/pipeline/stage-detail-sheet'
|
||||
import { usePipelineInlineEdit } from '@/hooks/use-pipeline-inline-edit'
|
||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
||||
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
||||
import { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor'
|
||||
import { defaultNotificationConfig } from '@/lib/pipeline-defaults'
|
||||
import { toWizardTrackConfig } from '@/lib/pipeline-conversions'
|
||||
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-700',
|
||||
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||
ARCHIVED: 'bg-muted text-muted-foreground',
|
||||
CLOSED: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
export default function PipelineDetailPage() {
|
||||
const params = useParams()
|
||||
const pipelineId = params.id as string
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
|
||||
const [selectedStageId, setSelectedStageId] = useState<string | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [structureTracks, setStructureTracks] = useState<WizardTrackConfig[]>([])
|
||||
const [notificationConfig, setNotificationConfig] = useState<Record<string, boolean>>({})
|
||||
const [overridePolicy, setOverridePolicy] = useState<Record<string, unknown>>({
|
||||
allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
|
||||
})
|
||||
const [structureDirty, setStructureDirty] = useState(false)
|
||||
const [settingsDirty, setSettingsDirty] = useState(false)
|
||||
|
||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
|
||||
id: pipelineId,
|
||||
})
|
||||
|
||||
const { isUpdating, updatePipeline, updateStageConfig } =
|
||||
usePipelineInlineEdit(pipelineId)
|
||||
|
||||
const publishMutation = trpc.pipeline.publish.useMutation({
|
||||
onSuccess: () => toast.success('Pipeline published'),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateMutation = trpc.pipeline.update.useMutation({
|
||||
onSuccess: () => toast.success('Pipeline updated'),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.pipeline.getDraft.invalidate({ id: pipelineId })
|
||||
toast.success('Pipeline structure updated')
|
||||
setStructureDirty(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const materializeRequirementsMutation =
|
||||
trpc.file.materializeRequirementsFromConfig.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
if (result.skipped && result.reason === 'already_materialized') {
|
||||
toast.message('Requirements already materialized')
|
||||
return
|
||||
}
|
||||
if (result.skipped && result.reason === 'no_config_requirements') {
|
||||
toast.message('No legacy config requirements found')
|
||||
return
|
||||
}
|
||||
await utils.file.listRequirements.invalidate()
|
||||
toast.success(`Materialized ${result.created} requirement(s)`)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Auto-select first track on load
|
||||
useEffect(() => {
|
||||
if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) {
|
||||
const firstTrack = pipeline.tracks.sort((a, b) => a.sortOrder - b.sortOrder)[0]
|
||||
setSelectedTrackId(firstTrack.id)
|
||||
}
|
||||
}, [pipeline, selectedTrackId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pipeline) return
|
||||
|
||||
const nextTracks = pipeline.tracks
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((track) =>
|
||||
toWizardTrackConfig({
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
slug: track.slug,
|
||||
kind: track.kind,
|
||||
sortOrder: track.sortOrder,
|
||||
routingMode: track.routingMode,
|
||||
decisionMode: track.decisionMode,
|
||||
stages: track.stages.map((stage) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
slug: stage.slug,
|
||||
stageType: stage.stageType,
|
||||
sortOrder: stage.sortOrder,
|
||||
configJson: stage.configJson,
|
||||
})),
|
||||
specialAward: track.specialAward
|
||||
? {
|
||||
name: track.specialAward.name,
|
||||
description: track.specialAward.description,
|
||||
scoringMode: track.specialAward.scoringMode,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
)
|
||||
setStructureTracks(nextTracks)
|
||||
|
||||
const settings = (pipeline.settingsJson as Record<string, unknown> | null) ?? {}
|
||||
setNotificationConfig(
|
||||
((settings.notificationConfig as Record<string, boolean> | undefined) ??
|
||||
defaultNotificationConfig()) as Record<string, boolean>
|
||||
)
|
||||
setOverridePolicy(
|
||||
((settings.overridePolicy as Record<string, unknown> | undefined) ?? {
|
||||
allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
setStructureDirty(false)
|
||||
setSettingsDirty(false)
|
||||
}, [pipeline])
|
||||
|
||||
const trackOptionsForEditors = useMemo(
|
||||
() =>
|
||||
(pipeline?.tracks ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((track) => ({
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
stages: track.stages
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((stage) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
sortOrder: stage.sortOrder,
|
||||
})),
|
||||
})),
|
||||
[pipeline]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<div>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!pipeline) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/rounds/pipelines">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Pipeline Not Found</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The requested pipeline does not exist
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedTrack = pipeline.tracks.find((t) => t.id === selectedTrackId)
|
||||
const selectedStage = selectedTrack?.stages.find(
|
||||
(s) => s.id === selectedStageId
|
||||
)
|
||||
const mainTrackDraft = structureTracks.find((track) => track.kind === 'MAIN')
|
||||
const hasAwardTracks = pipeline.tracks.some((t) => t.kind === 'AWARD')
|
||||
const hasMultipleTracks = pipeline.tracks.length > 1
|
||||
|
||||
const handleTrackChange = (trackId: string) => {
|
||||
setSelectedTrackId(trackId)
|
||||
setSelectedStageId(null)
|
||||
}
|
||||
|
||||
const handleStageSelect = (stageId: string) => {
|
||||
setSelectedStageId(stageId)
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
const handleStatusChange = async (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => {
|
||||
await updateMutation.mutateAsync({
|
||||
id: pipelineId,
|
||||
status: newStatus,
|
||||
})
|
||||
}
|
||||
|
||||
const updateMainTrackStages = (stages: WizardTrackConfig['stages']) => {
|
||||
setStructureTracks((prev) =>
|
||||
prev.map((track) =>
|
||||
track.kind === 'MAIN'
|
||||
? {
|
||||
...track,
|
||||
stages,
|
||||
}
|
||||
: track
|
||||
)
|
||||
)
|
||||
setStructureDirty(true)
|
||||
}
|
||||
|
||||
const handleSaveStructure = async () => {
|
||||
await updateStructureMutation.mutateAsync({
|
||||
id: pipelineId,
|
||||
tracks: structureTracks.map((track) => ({
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
slug: track.slug,
|
||||
kind: track.kind,
|
||||
sortOrder: track.sortOrder,
|
||||
routingModeDefault: track.routingModeDefault,
|
||||
decisionMode: track.decisionMode,
|
||||
stages: track.stages.map((stage) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
slug: stage.slug,
|
||||
stageType: stage.stageType,
|
||||
sortOrder: stage.sortOrder,
|
||||
configJson: stage.configJson,
|
||||
})),
|
||||
awardConfig: track.awardConfig,
|
||||
})),
|
||||
autoTransitions: false,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
const currentSettings = (pipeline.settingsJson as Record<string, unknown> | null) ?? {}
|
||||
await updatePipeline({
|
||||
settingsJson: {
|
||||
...currentSettings,
|
||||
notificationConfig,
|
||||
overridePolicy,
|
||||
},
|
||||
})
|
||||
setSettingsDirty(false)
|
||||
}
|
||||
|
||||
// Prepare flowchart data for the selected track
|
||||
const flowchartTracks = selectedTrack ? [selectedTrack] : []
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Link href="/admin/rounds/pipelines" className="mt-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<InlineEditableText
|
||||
value={pipeline.name}
|
||||
onSave={(newName) => updatePipeline({ name: newName })}
|
||||
variant="h1"
|
||||
placeholder="Untitled Pipeline"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors shrink-0',
|
||||
statusColors[pipeline.status] ?? '',
|
||||
'hover:opacity-80'
|
||||
)}
|
||||
>
|
||||
{pipeline.status}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange('DRAFT')}
|
||||
disabled={pipeline.status === 'DRAFT' || updateMutation.isPending}
|
||||
>
|
||||
Draft
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange('ACTIVE')}
|
||||
disabled={pipeline.status === 'ACTIVE' || updateMutation.isPending}
|
||||
>
|
||||
Active
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange('CLOSED')}
|
||||
disabled={pipeline.status === 'CLOSED' || updateMutation.isPending}
|
||||
>
|
||||
Closed
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange('ARCHIVED')}
|
||||
disabled={pipeline.status === 'ARCHIVED' || updateMutation.isPending}
|
||||
>
|
||||
Archived
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<span className="text-muted-foreground">slug:</span>
|
||||
<InlineEditableText
|
||||
value={pipeline.slug}
|
||||
onSave={(newSlug) => updatePipeline({ slug: newSlug })}
|
||||
variant="mono"
|
||||
placeholder="pipeline-slug"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/pipeline/${pipelineId}/wizard` as Route}>
|
||||
<Wand2 className="h-4 w-4 mr-2" />
|
||||
Edit in Wizard
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{pipeline.status === 'DRAFT' && (
|
||||
<DropdownMenuItem
|
||||
disabled={publishMutation.isPending}
|
||||
onClick={() => publishMutation.mutate({ id: pipelineId })}
|
||||
>
|
||||
{publishMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Rocket className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Publish
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{pipeline.status === 'ACTIVE' && (
|
||||
<DropdownMenuItem
|
||||
disabled={updateMutation.isPending}
|
||||
onClick={() => handleStatusChange('CLOSED')}
|
||||
>
|
||||
Close Pipeline
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={updateMutation.isPending}
|
||||
onClick={() => handleStatusChange('ARCHIVED')}
|
||||
>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline Summary */}
|
||||
<div className="grid gap-3 grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">Tracks</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{pipeline.tracks.length}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pipeline.tracks.filter((t) => t.kind === 'MAIN').length} main,{' '}
|
||||
{pipeline.tracks.filter((t) => t.kind === 'AWARD').length} award
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium">Stages</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{pipeline.tracks.reduce((sum, t) => sum + t.stages.length, 0)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">across all tracks</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm font-medium">Transitions</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{pipeline.tracks.reduce(
|
||||
(sum, t) =>
|
||||
sum +
|
||||
t.stages.reduce(
|
||||
(s, stage) => s + stage.transitionsFrom.length,
|
||||
0
|
||||
),
|
||||
0
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">stage connections</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Track Switcher (only if multiple tracks) */}
|
||||
{hasMultipleTracks && (
|
||||
<div className="flex items-center gap-2 flex-wrap overflow-x-auto pb-1">
|
||||
{pipeline.tracks
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((track) => (
|
||||
<button
|
||||
key={track.id}
|
||||
onClick={() => handleTrackChange(track.id)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||||
selectedTrackId === track.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<span>{track.name}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[9px] h-4 px-1',
|
||||
selectedTrackId === track.id
|
||||
? 'border-primary-foreground/20 text-primary-foreground/80'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
{track.kind}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline Flowchart */}
|
||||
{flowchartTracks.length > 0 ? (
|
||||
<div>
|
||||
<PipelineFlowchart
|
||||
tracks={flowchartTracks}
|
||||
selectedStageId={selectedStageId}
|
||||
onStageSelect={handleStageSelect}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Click a stage to edit its configuration
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No tracks configured for this pipeline
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stage Detail Sheet */}
|
||||
<StageDetailSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
stage={
|
||||
selectedStage
|
||||
? {
|
||||
id: selectedStage.id,
|
||||
name: selectedStage.name,
|
||||
stageType: selectedStage.stageType,
|
||||
configJson: selectedStage.configJson as Record<string, unknown> | null,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onSaveConfig={updateStageConfig}
|
||||
isSaving={isUpdating}
|
||||
pipelineId={pipelineId}
|
||||
materializeRequirements={(stageId) =>
|
||||
materializeRequirementsMutation.mutate({ stageId })
|
||||
}
|
||||
isMaterializing={materializeRequirementsMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Stage Management */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Stage Management</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Add, remove, reorder, or change stage types. Click a stage in the flowchart to edit its settings.
|
||||
</p>
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">Pipeline Structure</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSaveStructure}
|
||||
disabled={!structureDirty || updateStructureMutation.isPending}
|
||||
>
|
||||
{updateStructureMutation.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Save Structure
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mainTrackDraft ? (
|
||||
<MainTrackSection
|
||||
stages={mainTrackDraft.stages}
|
||||
onChange={updateMainTrackStages}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No main track configured.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<AwardsSection
|
||||
tracks={structureTracks}
|
||||
onChange={(tracks) => {
|
||||
setStructureTracks(tracks)
|
||||
setStructureDirty(true)
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Award Governance (only if award tracks exist) */}
|
||||
{hasAwardTracks && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Award Governance</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Configure special awards, voting, and scoring for award tracks.
|
||||
</p>
|
||||
<AwardGovernanceEditor
|
||||
pipelineId={pipelineId}
|
||||
tracks={pipeline.tracks
|
||||
.filter((track) => track.kind === 'AWARD')
|
||||
.map((track) => ({
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
decisionMode: track.decisionMode,
|
||||
specialAward: track.specialAward
|
||||
? {
|
||||
id: track.specialAward.id,
|
||||
name: track.specialAward.name,
|
||||
description: track.specialAward.description,
|
||||
criteriaText: track.specialAward.criteriaText,
|
||||
useAiEligibility: track.specialAward.useAiEligibility,
|
||||
scoringMode: track.specialAward.scoringMode,
|
||||
maxRankedPicks: track.specialAward.maxRankedPicks,
|
||||
votingStartAt: track.specialAward.votingStartAt,
|
||||
votingEndAt: track.specialAward.votingEndAt,
|
||||
status: track.specialAward.status,
|
||||
}
|
||||
: null,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Settings</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">Notifications and Overrides</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSaveSettings}
|
||||
disabled={!settingsDirty || isUpdating}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
<NotificationsSection
|
||||
config={notificationConfig}
|
||||
onChange={(next) => {
|
||||
setNotificationConfig(next)
|
||||
setSettingsDirty(true)
|
||||
}}
|
||||
overridePolicy={overridePolicy}
|
||||
onOverridePolicyChange={(next) => {
|
||||
setOverridePolicy(next)
|
||||
setSettingsDirty(true)
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useRouter, useParams } 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 } 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 { defaultNotificationConfig, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
|
||||
import { toWizardTrackConfig } from '@/lib/pipeline-conversions'
|
||||
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
|
||||
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
export default function EditPipelineWizardPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const pipelineId = params.id as string
|
||||
|
||||
const [state, setState] = useState<WizardState | null>(null)
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const initialStateRef = useRef<string>('')
|
||||
|
||||
// Load existing pipeline data
|
||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery(
|
||||
{ id: pipelineId },
|
||||
{ enabled: !!pipelineId }
|
||||
)
|
||||
|
||||
// Initialize state when pipeline data loads
|
||||
useEffect(() => {
|
||||
if (pipeline && !state) {
|
||||
const settings = (pipeline.settingsJson as Record<string, unknown> | null) ?? {}
|
||||
const initialState: WizardState = {
|
||||
name: pipeline.name,
|
||||
slug: pipeline.slug,
|
||||
programId: pipeline.programId,
|
||||
settingsJson: settings,
|
||||
tracks: pipeline.tracks
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map(track => toWizardTrackConfig({
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
slug: track.slug,
|
||||
kind: track.kind as 'MAIN' | 'AWARD' | 'SHOWCASE',
|
||||
sortOrder: track.sortOrder,
|
||||
routingMode: track.routingMode as 'SHARED' | 'EXCLUSIVE' | null,
|
||||
decisionMode: track.decisionMode as 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION' | null,
|
||||
stages: track.stages.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
stageType: s.stageType as 'INTAKE' | 'FILTER' | 'EVALUATION' | 'SELECTION' | 'LIVE_FINAL' | 'RESULTS',
|
||||
sortOrder: s.sortOrder,
|
||||
configJson: s.configJson,
|
||||
})),
|
||||
specialAward: track.specialAward ? {
|
||||
name: track.specialAward.name,
|
||||
description: track.specialAward.description,
|
||||
scoringMode: track.specialAward.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED',
|
||||
} : null,
|
||||
})),
|
||||
notificationConfig: (settings.notificationConfig as Record<string, boolean>) ?? defaultNotificationConfig(),
|
||||
overridePolicy: (settings.overridePolicy as Record<string, unknown>) ?? { allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
||||
}
|
||||
setState(initialState)
|
||||
initialStateRef.current = JSON.stringify(initialState)
|
||||
}
|
||||
}, [pipeline, state])
|
||||
|
||||
// Dirty tracking — warn on navigate away
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (state && JSON.stringify(state) !== initialStateRef.current) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [state])
|
||||
|
||||
const updateState = useCallback((updates: Partial<WizardState>) => {
|
||||
setState((prev) => prev ? { ...prev, ...updates } : prev)
|
||||
}, [])
|
||||
|
||||
// Mutations
|
||||
const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const updateSettingsMutation = trpc.pipeline.update.useMutation({
|
||||
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) => {
|
||||
if (!state) return
|
||||
|
||||
const validation = validateAll(state)
|
||||
if (!validation.valid) {
|
||||
toast.error('Please fix validation errors before saving')
|
||||
if (!validation.sections.basics.valid) setCurrentStep(0)
|
||||
else if (!validation.sections.tracks.valid) setCurrentStep(2)
|
||||
return
|
||||
}
|
||||
|
||||
await updateStructureMutation.mutateAsync({
|
||||
id: pipelineId,
|
||||
name: state.name,
|
||||
slug: state.slug,
|
||||
settingsJson: {
|
||||
...state.settingsJson,
|
||||
notificationConfig: state.notificationConfig,
|
||||
overridePolicy: state.overridePolicy,
|
||||
},
|
||||
tracks: state.tracks.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
kind: t.kind,
|
||||
sortOrder: t.sortOrder,
|
||||
routingModeDefault: t.routingModeDefault,
|
||||
decisionMode: t.decisionMode,
|
||||
stages: t.stages.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
stageType: s.stageType,
|
||||
sortOrder: s.sortOrder,
|
||||
configJson: s.configJson,
|
||||
})),
|
||||
awardConfig: t.awardConfig,
|
||||
})),
|
||||
autoTransitions: true,
|
||||
})
|
||||
|
||||
await updateSettingsMutation.mutateAsync({
|
||||
id: pipelineId,
|
||||
settingsJson: {
|
||||
...state.settingsJson,
|
||||
notificationConfig: state.notificationConfig,
|
||||
overridePolicy: state.overridePolicy,
|
||||
},
|
||||
})
|
||||
|
||||
if (publish) {
|
||||
await publishMutation.mutateAsync({ id: pipelineId })
|
||||
}
|
||||
|
||||
initialStateRef.current = JSON.stringify(state)
|
||||
toast.success(publish ? 'Pipeline saved and published' : 'Pipeline changes saved')
|
||||
router.push(`/admin/rounds/pipeline/${pipelineId}` as Route)
|
||||
}
|
||||
|
||||
const isSaving = updateStructureMutation.isPending && !publishMutation.isPending
|
||||
const isSubmitting = publishMutation.isPending
|
||||
|
||||
// Loading state
|
||||
if (isLoading || !state) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/admin/rounds/pipeline/${pipelineId}` as Route}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Edit Pipeline (Wizard)</h1>
|
||||
<p className="text-sm text-muted-foreground">Loading pipeline data...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 = (stageType: string, configJson: Record<string, unknown>) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) => {
|
||||
if (track.kind !== 'MAIN') return track
|
||||
return {
|
||||
...track,
|
||||
stages: track.stages.map((stage) =>
|
||||
stage.stageType === stageType ? { ...stage, configJson } : stage
|
||||
),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateMainTrackStages = (stages: WizardState['tracks'][0]['stages']) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...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
|
||||
|
||||
// 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,
|
||||
},
|
||||
{
|
||||
title: 'Live Finals',
|
||||
description: 'Voting and reveal settings',
|
||||
isValid: !!liveStage,
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
description: 'Event notifications',
|
||||
isValid: true,
|
||||
},
|
||||
{
|
||||
title: 'Review & Save',
|
||||
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/pipeline/${pipelineId}` as Route}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Edit Pipeline (Wizard)</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Modify the 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 Changes"
|
||||
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 & Save */}
|
||||
<div>
|
||||
<ReviewSection state={state} />
|
||||
</div>
|
||||
</SidebarStepper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Plus,
|
||||
Layers,
|
||||
Calendar,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
|
||||
const statusConfig = {
|
||||
DRAFT: {
|
||||
label: 'Draft',
|
||||
bgClass: 'bg-gray-100 text-gray-700',
|
||||
dotClass: 'bg-gray-500',
|
||||
},
|
||||
ACTIVE: {
|
||||
label: 'Active',
|
||||
bgClass: 'bg-emerald-100 text-emerald-700',
|
||||
dotClass: 'bg-emerald-500',
|
||||
},
|
||||
CLOSED: {
|
||||
label: 'Closed',
|
||||
bgClass: 'bg-blue-100 text-blue-700',
|
||||
dotClass: 'bg-blue-500',
|
||||
},
|
||||
ARCHIVED: {
|
||||
label: 'Archived',
|
||||
bgClass: 'bg-muted text-muted-foreground',
|
||||
dotClass: 'bg-muted-foreground',
|
||||
},
|
||||
} as const
|
||||
|
||||
export default function PipelineListPage() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id
|
||||
|
||||
const { data: pipelines, isLoading } = trpc.pipeline.list.useQuery(
|
||||
{ programId: programId! },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
if (!programId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Pipelines</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select an edition to view pipelines
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Calendar className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Edition Selected</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select an edition from the sidebar to view its pipelines
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Pipelines</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage evaluation pipelines for {currentEdition?.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create Pipeline
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-20 mt-1" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4 mt-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && (!pipelines || pipelines.length === 0) && (
|
||||
<Card className="border-2 border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="rounded-full bg-primary/10 p-4 mb-4">
|
||||
<Workflow className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Pipelines Yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
||||
Pipelines organize your project evaluation workflow into tracks and stages.
|
||||
Create your first pipeline to get started with managing project evaluations.
|
||||
</p>
|
||||
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Your First Pipeline
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pipeline Cards */}
|
||||
{pipelines && pipelines.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{pipelines.map((pipeline) => {
|
||||
const status = pipeline.status as keyof typeof statusConfig
|
||||
const config = statusConfig[status] || statusConfig.DRAFT
|
||||
const description = (pipeline.settingsJson as Record<string, unknown> | null)?.description as string | undefined
|
||||
|
||||
return (
|
||||
<Link key={pipeline.id} href={`/admin/rounds/pipeline/${pipeline.id}` as Route}>
|
||||
<Card className="group cursor-pointer hover:shadow-md transition-shadow h-full flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base leading-tight">
|
||||
{pipeline.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[10px] shrink-0 flex items-center gap-1.5',
|
||||
config.bgClass
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', config.dotClass)} />
|
||||
{config.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="mt-auto">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">
|
||||
{pipeline._count.tracks === 0
|
||||
? 'No tracks'
|
||||
: pipeline._count.tracks === 1
|
||||
? '1 track'
|
||||
: `${pipeline._count.tracks} tracks`}
|
||||
</span>
|
||||
</div>
|
||||
<span>Updated {formatDistanceToNow(new Date(pipeline.updatedAt))} ago</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user