Round system redesign: Phases 1-7 complete
Full pipeline/track/stage architecture replacing the legacy round system. Schema: 11 new models (Pipeline, Track, Stage, StageTransition, ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor, OverrideAction, AudienceVoter) + 8 new enums. Backend: 9 new routers (pipeline, stage, routing, stageFiltering, stageAssignment, cohort, live, decision, award) + 6 new services (stage-engine, routing-engine, stage-filtering, stage-assignment, stage-notifications, live-control). Frontend: Pipeline wizard (17 components), jury stage pages (7), applicant pipeline pages (3), public stage pages (2), admin pipeline pages (5), shared stage components (3), SSE route, live hook. Phase 6 refit: 23 routers/services migrated from roundId to stageId, all frontend components refitted. Deleted round.ts (985 lines), roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx, 10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs. Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing, TypeScript 0 errors, Next.js build succeeds, 13 integrity checks, legacy symbol sweep clean, auto-seed on first Docker startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
554
src/app/(admin)/admin/rounds/pipeline/[id]/advanced/page.tsx
Normal file
554
src/app/(admin)/admin/rounds/pipeline/[id]/advanced/page.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route as NextRoute } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
Layers,
|
||||
GitBranch,
|
||||
Route,
|
||||
Play,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { PipelineVisualization } from '@/components/admin/pipeline/pipeline-visualization'
|
||||
|
||||
const stageTypeColors: Record<string, string> = {
|
||||
INTAKE: 'text-blue-600',
|
||||
FILTER: 'text-amber-600',
|
||||
EVALUATION: 'text-purple-600',
|
||||
SELECTION: 'text-rose-600',
|
||||
LIVE_FINAL: 'text-emerald-600',
|
||||
RESULTS: 'text-cyan-600',
|
||||
}
|
||||
|
||||
type SelectedItem =
|
||||
| { type: 'stage'; trackId: string; stageId: string }
|
||||
| { type: 'track'; trackId: string }
|
||||
| null
|
||||
|
||||
export default function AdvancedEditorPage() {
|
||||
const params = useParams()
|
||||
const pipelineId = params.id as string
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<SelectedItem>(null)
|
||||
const [configEditValue, setConfigEditValue] = useState('')
|
||||
const [simulationProjectIds, setSimulationProjectIds] = useState('')
|
||||
const [showSaveConfirm, setShowSaveConfirm] = useState(false)
|
||||
|
||||
const { data: pipeline, isLoading, refetch } = trpc.pipeline.getDraft.useQuery({
|
||||
id: pipelineId,
|
||||
})
|
||||
|
||||
const updateConfigMutation = trpc.stage.updateConfig.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Stage config saved')
|
||||
refetch()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const simulateMutation = trpc.pipeline.simulate.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Simulation complete: ${data.simulations?.length ?? 0} results`)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const { data: routingRules } = trpc.routing.listRules.useQuery(
|
||||
{ pipelineId },
|
||||
{ enabled: !!pipelineId }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<Skeleton className="col-span-3 h-96" />
|
||||
<Skeleton className="col-span-5 h-96" />
|
||||
<Skeleton className="col-span-4 h-96" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!pipeline) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={'/admin/rounds/pipelines' as NextRoute}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">Pipeline Not Found</h1>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSelectStage = (trackId: string, stageId: string) => {
|
||||
setSelectedItem({ type: 'stage', trackId, stageId })
|
||||
const track = pipeline.tracks.find((t) => t.id === trackId)
|
||||
const stage = track?.stages.find((s) => s.id === stageId)
|
||||
setConfigEditValue(
|
||||
JSON.stringify(stage?.configJson ?? {}, null, 2)
|
||||
)
|
||||
}
|
||||
|
||||
const executeSaveConfig = () => {
|
||||
if (selectedItem?.type !== 'stage') return
|
||||
try {
|
||||
const parsed = JSON.parse(configEditValue)
|
||||
updateConfigMutation.mutate({
|
||||
id: selectedItem.stageId,
|
||||
configJson: parsed,
|
||||
})
|
||||
} catch {
|
||||
toast.error('Invalid JSON in config editor')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveConfig = () => {
|
||||
if (selectedItem?.type !== 'stage') return
|
||||
// Validate JSON first
|
||||
try {
|
||||
JSON.parse(configEditValue)
|
||||
} catch {
|
||||
toast.error('Invalid JSON in config editor')
|
||||
return
|
||||
}
|
||||
// If pipeline is active or stage has projects, require confirmation
|
||||
const stage = pipeline?.tracks
|
||||
.flatMap((t) => t.stages)
|
||||
.find((s) => s.id === selectedItem.stageId)
|
||||
const hasProjects = (stage?._count?.projectStageStates ?? 0) > 0
|
||||
const isActive = pipeline?.status === 'ACTIVE'
|
||||
if (isActive || hasProjects) {
|
||||
setShowSaveConfirm(true)
|
||||
} else {
|
||||
executeSaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSimulate = () => {
|
||||
const ids = simulationProjectIds
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
if (ids.length === 0) {
|
||||
toast.error('Enter at least one project ID')
|
||||
return
|
||||
}
|
||||
simulateMutation.mutate({ id: pipelineId, projectIds: ids })
|
||||
}
|
||||
|
||||
const selectedTrack =
|
||||
selectedItem?.type === 'stage'
|
||||
? pipeline.tracks.find((t) => t.id === selectedItem.trackId)
|
||||
: selectedItem?.type === 'track'
|
||||
? pipeline.tracks.find((t) => t.id === selectedItem.trackId)
|
||||
: null
|
||||
|
||||
const selectedStage =
|
||||
selectedItem?.type === 'stage'
|
||||
? selectedTrack?.stages.find((s) => s.id === selectedItem.stageId)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/admin/rounds/pipeline/${pipelineId}` as NextRoute}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Advanced Editor</h1>
|
||||
<p className="text-sm text-muted-foreground">{pipeline.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visualization */}
|
||||
<PipelineVisualization tracks={pipeline.tracks} />
|
||||
|
||||
{/* Five Panel Layout */}
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Panel 1 — Track/Stage Tree (left sidebar) */}
|
||||
<div className="col-span-12 lg:col-span-3">
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Layers className="h-4 w-4" />
|
||||
Structure
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 max-h-[600px] overflow-y-auto">
|
||||
{pipeline.tracks
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((track) => (
|
||||
<div key={track.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full text-left px-2 py-1.5 rounded text-sm font-medium hover:bg-muted transition-colors',
|
||||
selectedItem?.type === 'track' &&
|
||||
selectedItem.trackId === track.id
|
||||
? 'bg-muted'
|
||||
: ''
|
||||
)}
|
||||
onClick={() =>
|
||||
setSelectedItem({ type: 'track', trackId: track.id })
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span>{track.name}</span>
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1 ml-auto">
|
||||
{track.kind}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
<div className="ml-4 space-y-0.5 mt-0.5">
|
||||
{track.stages
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((stage) => (
|
||||
<button
|
||||
key={stage.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full text-left px-2 py-1 rounded text-xs hover:bg-muted transition-colors',
|
||||
selectedItem?.type === 'stage' &&
|
||||
selectedItem.stageId === stage.id
|
||||
? 'bg-muted font-medium'
|
||||
: ''
|
||||
)}
|
||||
onClick={() =>
|
||||
handleSelectStage(track.id, stage.id)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-mono',
|
||||
stageTypeColors[stage.stageType] ?? ''
|
||||
)}
|
||||
>
|
||||
{stage.stageType.slice(0, 3)}
|
||||
</span>
|
||||
<span className="truncate">{stage.name}</span>
|
||||
{stage._count?.projectStageStates > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] h-3.5 px-1 ml-auto"
|
||||
>
|
||||
{stage._count.projectStageStates}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Panel 2 — Stage Config Editor (center) */}
|
||||
<div className="col-span-12 lg:col-span-5">
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">
|
||||
{selectedStage
|
||||
? `${selectedStage.name} Config`
|
||||
: selectedTrack
|
||||
? `${selectedTrack.name} Track`
|
||||
: 'Select a stage'}
|
||||
</CardTitle>
|
||||
{selectedStage && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={updateConfigMutation.isPending}
|
||||
onClick={handleSaveConfig}
|
||||
>
|
||||
{updateConfigMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedStage ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{selectedStage.stageType}
|
||||
</Badge>
|
||||
<span className="font-mono">{selectedStage.slug}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={configEditValue}
|
||||
onChange={(e) => setConfigEditValue(e.target.value)}
|
||||
className="font-mono text-xs min-h-[400px]"
|
||||
placeholder="{ }"
|
||||
/>
|
||||
</div>
|
||||
) : selectedTrack ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Kind</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedTrack.kind}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Routing Mode</span>
|
||||
<span className="text-xs font-mono">
|
||||
{selectedTrack.routingMode ?? 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Decision Mode</span>
|
||||
<span className="text-xs font-mono">
|
||||
{selectedTrack.decisionMode ?? 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Stages</span>
|
||||
<span className="font-medium">
|
||||
{selectedTrack.stages.length}
|
||||
</span>
|
||||
</div>
|
||||
{selectedTrack.specialAward && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<p className="text-xs font-medium mb-1">Special Award</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedTrack.specialAward.name} —{' '}
|
||||
{selectedTrack.specialAward.scoringMode}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
Select a track or stage from the tree to view or edit its
|
||||
configuration
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Panel 3+4+5 — Routing + Transitions + Simulation (right sidebar) */}
|
||||
<div className="col-span-12 lg:col-span-4 space-y-4">
|
||||
{/* Routing Rules */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Route className="h-4 w-4" />
|
||||
Routing Rules
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{routingRules && routingRules.length > 0 ? (
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{routingRules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center gap-2 text-xs py-1.5 border-b last:border-0"
|
||||
>
|
||||
<Badge
|
||||
variant={rule.isActive ? 'default' : 'secondary'}
|
||||
className="text-[9px] h-4 shrink-0"
|
||||
>
|
||||
P{rule.priority}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{rule.sourceTrack?.name ?? '—'} →{' '}
|
||||
{rule.destinationTrack?.name ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground py-3 text-center">
|
||||
No routing rules configured
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transitions */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Transitions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(() => {
|
||||
const allTransitions = pipeline.tracks.flatMap((track) =>
|
||||
track.stages.flatMap((stage) =>
|
||||
stage.transitionsFrom.map((t) => ({
|
||||
id: t.id,
|
||||
fromName: stage.name,
|
||||
toName: t.toStage?.name ?? '?',
|
||||
isDefault: t.isDefault,
|
||||
}))
|
||||
)
|
||||
)
|
||||
return allTransitions.length > 0 ? (
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{allTransitions.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center gap-1 text-xs py-1 border-b last:border-0"
|
||||
>
|
||||
<span className="truncate">{t.fromName}</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="truncate">{t.toName}</span>
|
||||
{t.isDefault && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[8px] h-3.5 ml-auto shrink-0"
|
||||
>
|
||||
default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground py-3 text-center">
|
||||
No transitions defined
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Simulation */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Play className="h-4 w-4" />
|
||||
Simulation
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Test where projects would route
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">Project IDs (comma-separated)</Label>
|
||||
<Input
|
||||
value={simulationProjectIds}
|
||||
onChange={(e) => setSimulationProjectIds(e.target.value)}
|
||||
placeholder="id1, id2, id3"
|
||||
className="text-xs mt-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={simulateMutation.isPending || !simulationProjectIds.trim()}
|
||||
onClick={handleSimulate}
|
||||
>
|
||||
{simulateMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
Run Simulation
|
||||
</Button>
|
||||
{simulateMutation.data?.simulations && (
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{simulateMutation.data.simulations.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs py-1 border-b last:border-0"
|
||||
>
|
||||
<span className="font-mono">{r.projectId.slice(0, 8)}</span>
|
||||
<span className="text-muted-foreground"> → </span>
|
||||
<span>{r.targetTrackName ?? 'unrouted'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog for destructive config saves */}
|
||||
<AlertDialog open={showSaveConfirm} onOpenChange={setShowSaveConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Save Stage Configuration?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This stage belongs to an active pipeline or has projects assigned to it.
|
||||
Changing the configuration may affect ongoing evaluations and project processing.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setShowSaveConfirm(false)
|
||||
executeSaveConfig()
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
422
src/app/(admin)/admin/rounds/pipeline/[id]/edit/page.tsx
Normal file
422
src/app/(admin)/admin/rounds/pipeline/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, Loader2, Save } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
|
||||
import { WizardSection } from '@/components/admin/pipeline/wizard-section'
|
||||
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
|
||||
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
||||
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
|
||||
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
|
||||
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
||||
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
|
||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
||||
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
|
||||
|
||||
import {
|
||||
defaultIntakeConfig,
|
||||
defaultFilterConfig,
|
||||
defaultEvaluationConfig,
|
||||
defaultLiveConfig,
|
||||
defaultNotificationConfig,
|
||||
} from '@/lib/pipeline-defaults'
|
||||
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
|
||||
import type {
|
||||
WizardState,
|
||||
IntakeConfig,
|
||||
FilterConfig,
|
||||
EvaluationConfig,
|
||||
LiveFinalConfig,
|
||||
WizardTrackConfig,
|
||||
} from '@/types/pipeline-wizard'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function pipelineToWizardState(pipeline: any): WizardState {
|
||||
const settings = (pipeline.settingsJson as Record<string, unknown>) ?? {}
|
||||
|
||||
return {
|
||||
name: pipeline.name,
|
||||
slug: pipeline.slug,
|
||||
programId: pipeline.programId,
|
||||
settingsJson: settings,
|
||||
tracks: (pipeline.tracks ?? []).map((t: any) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
kind: t.kind as WizardTrackConfig['kind'],
|
||||
sortOrder: t.sortOrder,
|
||||
routingModeDefault: t.routingMode as WizardTrackConfig['routingModeDefault'],
|
||||
decisionMode: t.decisionMode as WizardTrackConfig['decisionMode'],
|
||||
stages: (t.stages ?? []).map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
stageType: s.stageType as WizardTrackConfig['stages'][0]['stageType'],
|
||||
sortOrder: s.sortOrder,
|
||||
configJson: (s.configJson as Record<string, unknown>) ?? {},
|
||||
})),
|
||||
awardConfig: t.specialAward
|
||||
? {
|
||||
name: t.specialAward.name,
|
||||
description: t.specialAward.description ?? undefined,
|
||||
scoringMode: t.specialAward.scoringMode as NonNullable<WizardTrackConfig['awardConfig']>['scoringMode'],
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
notificationConfig:
|
||||
(settings.notificationConfig as Record<string, boolean>) ??
|
||||
defaultNotificationConfig(),
|
||||
overridePolicy:
|
||||
(settings.overridePolicy as Record<string, unknown>) ?? {
|
||||
allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default function EditPipelinePage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const pipelineId = params.id as string
|
||||
|
||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
|
||||
id: pipelineId,
|
||||
})
|
||||
|
||||
const [state, setState] = useState<WizardState | null>(null)
|
||||
const [openSection, setOpenSection] = useState(0)
|
||||
const initialStateRef = useRef<string>('')
|
||||
|
||||
// Initialize state from pipeline data
|
||||
useEffect(() => {
|
||||
if (pipeline && !state) {
|
||||
const wizardState = pipelineToWizardState(pipeline)
|
||||
setState(wizardState)
|
||||
initialStateRef.current = JSON.stringify(wizardState)
|
||||
}
|
||||
}, [pipeline, state])
|
||||
|
||||
// Dirty tracking
|
||||
useEffect(() => {
|
||||
if (!state) return
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (JSON.stringify(state) !== initialStateRef.current) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [state])
|
||||
|
||||
const updateState = useCallback((updates: Partial<WizardState>) => {
|
||||
setState((prev) => (prev ? { ...prev, ...updates } : null))
|
||||
}, [])
|
||||
|
||||
const updateStageConfig = useCallback(
|
||||
(stageType: string, configJson: Record<string, unknown>) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) => {
|
||||
if (track.kind !== 'MAIN') return track
|
||||
return {
|
||||
...track,
|
||||
stages: track.stages.map((stage) =>
|
||||
stage.stageType === stageType ? { ...stage, configJson } : stage
|
||||
),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const updateMainTrackStages = useCallback(
|
||||
(stages: WizardState['tracks'][0]['stages']) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) =>
|
||||
track.kind === 'MAIN' ? { ...track, stages } : track
|
||||
),
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
|
||||
onSuccess: () => {
|
||||
if (state) initialStateRef.current = JSON.stringify(state)
|
||||
toast.success('Pipeline updated successfully')
|
||||
router.push(`/admin/rounds/pipeline/${pipelineId}` as Route)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading || !state) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<div>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mainTrack = state.tracks.find((t) => t.kind === 'MAIN')
|
||||
const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE')
|
||||
const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER')
|
||||
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
|
||||
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
|
||||
|
||||
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
|
||||
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
|
||||
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
|
||||
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
|
||||
|
||||
const basicsValid = validateBasics(state).valid
|
||||
const tracksValid = validateTracks(state.tracks).valid
|
||||
const allValid = validateAll(state).valid
|
||||
const isActive = pipeline?.status === 'ACTIVE'
|
||||
|
||||
const handleSave = async () => {
|
||||
const validation = validateAll(state)
|
||||
if (!validation.valid) {
|
||||
toast.error('Please fix validation errors before saving')
|
||||
if (!validation.sections.basics.valid) setOpenSection(0)
|
||||
else if (!validation.sections.tracks.valid) setOpenSection(2)
|
||||
return
|
||||
}
|
||||
|
||||
await updateStructureMutation.mutateAsync({
|
||||
id: pipelineId,
|
||||
name: state.name,
|
||||
slug: state.slug,
|
||||
settingsJson: {
|
||||
...state.settingsJson,
|
||||
notificationConfig: state.notificationConfig,
|
||||
overridePolicy: state.overridePolicy,
|
||||
},
|
||||
tracks: state.tracks.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
kind: t.kind,
|
||||
sortOrder: t.sortOrder,
|
||||
routingModeDefault: t.routingModeDefault,
|
||||
decisionMode: t.decisionMode,
|
||||
stages: t.stages.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
stageType: s.stageType,
|
||||
sortOrder: s.sortOrder,
|
||||
configJson: s.configJson,
|
||||
})),
|
||||
awardConfig: t.awardConfig,
|
||||
})),
|
||||
autoTransitions: true,
|
||||
})
|
||||
}
|
||||
|
||||
const isSaving = updateStructureMutation.isPending
|
||||
|
||||
const sections = [
|
||||
{ title: 'Basics', description: 'Pipeline name, slug, and program', isValid: basicsValid },
|
||||
{ title: 'Intake', description: 'Submission windows and file requirements', isValid: !!intakeStage },
|
||||
{ title: 'Main Track Stages', description: `${mainTrack?.stages.length ?? 0} stages configured`, isValid: tracksValid },
|
||||
{ title: 'Filtering', description: 'Gate rules and AI screening settings', isValid: !!filterStage },
|
||||
{ title: 'Assignment', description: 'Jury evaluation assignment strategy', isValid: !!evalStage },
|
||||
{ title: 'Awards', description: `${state.tracks.filter((t) => t.kind === 'AWARD').length} award tracks`, isValid: true },
|
||||
{ title: 'Live Finals', description: 'Voting, cohorts, and reveal settings', isValid: !!liveStage },
|
||||
{ title: 'Notifications', description: 'Event notifications and override governance', isValid: true },
|
||||
{ title: 'Review', description: 'Validation summary', isValid: allValid },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/admin/rounds/pipeline/${pipelineId}` as Route}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Edit Pipeline</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pipeline?.name}
|
||||
{isActive && ' (Active — some fields are locked)'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSaving || !allValid}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Wizard Sections */}
|
||||
<div className="space-y-3">
|
||||
<WizardSection
|
||||
stepNumber={1}
|
||||
title={sections[0].title}
|
||||
description={sections[0].description}
|
||||
isOpen={openSection === 0}
|
||||
onToggle={() => setOpenSection(openSection === 0 ? -1 : 0)}
|
||||
isValid={sections[0].isValid}
|
||||
>
|
||||
<BasicsSection state={state} onChange={updateState} isActive={isActive} />
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={2}
|
||||
title={sections[1].title}
|
||||
description={sections[1].description}
|
||||
isOpen={openSection === 1}
|
||||
onToggle={() => setOpenSection(openSection === 1 ? -1 : 1)}
|
||||
isValid={sections[1].isValid}
|
||||
>
|
||||
<IntakeSection
|
||||
config={intakeConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={3}
|
||||
title={sections[2].title}
|
||||
description={sections[2].description}
|
||||
isOpen={openSection === 2}
|
||||
onToggle={() => setOpenSection(openSection === 2 ? -1 : 2)}
|
||||
isValid={sections[2].isValid}
|
||||
>
|
||||
<MainTrackSection
|
||||
stages={mainTrack?.stages ?? []}
|
||||
onChange={updateMainTrackStages}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={4}
|
||||
title={sections[3].title}
|
||||
description={sections[3].description}
|
||||
isOpen={openSection === 3}
|
||||
onToggle={() => setOpenSection(openSection === 3 ? -1 : 3)}
|
||||
isValid={sections[3].isValid}
|
||||
>
|
||||
<FilteringSection
|
||||
config={filterConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={5}
|
||||
title={sections[4].title}
|
||||
description={sections[4].description}
|
||||
isOpen={openSection === 4}
|
||||
onToggle={() => setOpenSection(openSection === 4 ? -1 : 4)}
|
||||
isValid={sections[4].isValid}
|
||||
>
|
||||
<AssignmentSection
|
||||
config={evalConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={6}
|
||||
title={sections[5].title}
|
||||
description={sections[5].description}
|
||||
isOpen={openSection === 5}
|
||||
onToggle={() => setOpenSection(openSection === 5 ? -1 : 5)}
|
||||
isValid={sections[5].isValid}
|
||||
>
|
||||
<AwardsSection
|
||||
tracks={state.tracks}
|
||||
onChange={(tracks) => updateState({ tracks })}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={7}
|
||||
title={sections[6].title}
|
||||
description={sections[6].description}
|
||||
isOpen={openSection === 6}
|
||||
onToggle={() => setOpenSection(openSection === 6 ? -1 : 6)}
|
||||
isValid={sections[6].isValid}
|
||||
>
|
||||
<LiveFinalsSection
|
||||
config={liveConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={8}
|
||||
title={sections[7].title}
|
||||
description={sections[7].description}
|
||||
isOpen={openSection === 7}
|
||||
onToggle={() => setOpenSection(openSection === 7 ? -1 : 7)}
|
||||
isValid={sections[7].isValid}
|
||||
>
|
||||
<NotificationsSection
|
||||
config={state.notificationConfig}
|
||||
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
||||
overridePolicy={state.overridePolicy}
|
||||
onOverridePolicyChange={(overridePolicy) =>
|
||||
updateState({ overridePolicy })
|
||||
}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={9}
|
||||
title={sections[8].title}
|
||||
description={sections[8].description}
|
||||
isOpen={openSection === 8}
|
||||
onToggle={() => setOpenSection(openSection === 8 ? -1 : 8)}
|
||||
isValid={sections[8].isValid}
|
||||
>
|
||||
<ReviewSection state={state} />
|
||||
</WizardSection>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
439
src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx
Normal file
439
src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
MoreHorizontal,
|
||||
Rocket,
|
||||
Archive,
|
||||
Settings2,
|
||||
Layers,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { IntakePanel } from '@/components/admin/pipeline/stage-panels/intake-panel'
|
||||
import { FilterPanel } from '@/components/admin/pipeline/stage-panels/filter-panel'
|
||||
import { EvaluationPanel } from '@/components/admin/pipeline/stage-panels/evaluation-panel'
|
||||
import { SelectionPanel } from '@/components/admin/pipeline/stage-panels/selection-panel'
|
||||
import { LiveFinalPanel } from '@/components/admin/pipeline/stage-panels/live-final-panel'
|
||||
import { ResultsPanel } from '@/components/admin/pipeline/stage-panels/results-panel'
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-700',
|
||||
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||
ARCHIVED: 'bg-muted text-muted-foreground',
|
||||
CLOSED: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
const stageTypeColors: Record<string, string> = {
|
||||
INTAKE: 'bg-blue-100 text-blue-700',
|
||||
FILTER: 'bg-amber-100 text-amber-700',
|
||||
EVALUATION: 'bg-purple-100 text-purple-700',
|
||||
SELECTION: 'bg-rose-100 text-rose-700',
|
||||
LIVE_FINAL: 'bg-emerald-100 text-emerald-700',
|
||||
RESULTS: 'bg-cyan-100 text-cyan-700',
|
||||
}
|
||||
|
||||
function StagePanel({
|
||||
stageId,
|
||||
stageType,
|
||||
configJson,
|
||||
}: {
|
||||
stageId: string
|
||||
stageType: string
|
||||
configJson: Record<string, unknown> | null
|
||||
}) {
|
||||
switch (stageType) {
|
||||
case 'INTAKE':
|
||||
return <IntakePanel stageId={stageId} configJson={configJson} />
|
||||
case 'FILTER':
|
||||
return <FilterPanel stageId={stageId} configJson={configJson} />
|
||||
case 'EVALUATION':
|
||||
return <EvaluationPanel stageId={stageId} configJson={configJson} />
|
||||
case 'SELECTION':
|
||||
return <SelectionPanel stageId={stageId} configJson={configJson} />
|
||||
case 'LIVE_FINAL':
|
||||
return <LiveFinalPanel stageId={stageId} configJson={configJson} />
|
||||
case 'RESULTS':
|
||||
return <ResultsPanel stageId={stageId} configJson={configJson} />
|
||||
default:
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
Unknown stage type: {stageType}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default function PipelineDetailPage() {
|
||||
const params = useParams()
|
||||
const pipelineId = params.id as string
|
||||
|
||||
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
|
||||
const [selectedStageId, setSelectedStageId] = useState<string | null>(null)
|
||||
|
||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
|
||||
id: pipelineId,
|
||||
})
|
||||
|
||||
// Auto-select first track and stage
|
||||
useEffect(() => {
|
||||
if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) {
|
||||
const firstTrack = pipeline.tracks[0]
|
||||
setSelectedTrackId(firstTrack.id)
|
||||
if (firstTrack.stages.length > 0) {
|
||||
setSelectedStageId(firstTrack.stages[0].id)
|
||||
}
|
||||
}
|
||||
}, [pipeline, selectedTrackId])
|
||||
|
||||
const publishMutation = trpc.pipeline.publish.useMutation({
|
||||
onSuccess: () => toast.success('Pipeline published'),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateMutation = trpc.pipeline.update.useMutation({
|
||||
onSuccess: () => toast.success('Pipeline updated'),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<div>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!pipeline) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/rounds/pipelines">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Pipeline Not Found</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The requested pipeline does not exist
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedTrack = pipeline.tracks.find((t) => t.id === selectedTrackId)
|
||||
const selectedStage = selectedTrack?.stages.find(
|
||||
(s) => s.id === selectedStageId
|
||||
)
|
||||
|
||||
const handleTrackChange = (trackId: string) => {
|
||||
setSelectedTrackId(trackId)
|
||||
const track = pipeline.tracks.find((t) => t.id === trackId)
|
||||
if (track && track.stages.length > 0) {
|
||||
setSelectedStageId(track.stages[0].id)
|
||||
} else {
|
||||
setSelectedStageId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/rounds/pipelines">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold">{pipeline.name}</h1>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[10px]',
|
||||
statusColors[pipeline.status] ?? ''
|
||||
)}
|
||||
>
|
||||
{pipeline.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
{pipeline.slug}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/admin/rounds/pipeline/${pipelineId}/edit` as Route}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/admin/rounds/pipeline/${pipelineId}/advanced` as Route}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings2 className="h-4 w-4 mr-1" />
|
||||
Advanced
|
||||
</Button>
|
||||
</Link>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{pipeline.status === 'DRAFT' && (
|
||||
<DropdownMenuItem
|
||||
disabled={publishMutation.isPending}
|
||||
onClick={() =>
|
||||
publishMutation.mutate({ id: pipelineId })
|
||||
}
|
||||
>
|
||||
{publishMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Rocket className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Publish
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{pipeline.status === 'ACTIVE' && (
|
||||
<DropdownMenuItem
|
||||
disabled={updateMutation.isPending}
|
||||
onClick={() =>
|
||||
updateMutation.mutate({
|
||||
id: pipelineId,
|
||||
status: 'CLOSED',
|
||||
})
|
||||
}
|
||||
>
|
||||
Close Pipeline
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={updateMutation.isPending}
|
||||
onClick={() =>
|
||||
updateMutation.mutate({
|
||||
id: pipelineId,
|
||||
status: 'ARCHIVED',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline Summary */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">Tracks</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{pipeline.tracks.length}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pipeline.tracks.filter((t) => t.kind === 'MAIN').length} main,{' '}
|
||||
{pipeline.tracks.filter((t) => t.kind === 'AWARD').length} award
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium">Stages</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{pipeline.tracks.reduce((sum, t) => sum + t.stages.length, 0)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">across all tracks</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm font-medium">Transitions</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{pipeline.tracks.reduce(
|
||||
(sum, t) =>
|
||||
sum +
|
||||
t.stages.reduce(
|
||||
(s, stage) => s + stage.transitionsFrom.length,
|
||||
0
|
||||
),
|
||||
0
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">stage connections</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Track Tabs */}
|
||||
{pipeline.tracks.length > 0 && (
|
||||
<Tabs
|
||||
value={selectedTrackId ?? undefined}
|
||||
onValueChange={handleTrackChange}
|
||||
>
|
||||
<TabsList className="w-full justify-start overflow-x-auto">
|
||||
{pipeline.tracks
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((track) => (
|
||||
<TabsTrigger
|
||||
key={track.id}
|
||||
value={track.id}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<span>{track.name}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] h-4 px-1"
|
||||
>
|
||||
{track.kind}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{pipeline.tracks.map((track) => (
|
||||
<TabsContent key={track.id} value={track.id} className="mt-4">
|
||||
{/* Track Info */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-sm">{track.name}</CardTitle>
|
||||
<CardDescription className="font-mono text-xs">
|
||||
{track.slug}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{track.routingMode && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{track.routingMode}
|
||||
</Badge>
|
||||
)}
|
||||
{track.decisionMode && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{track.decisionMode}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Stage Tabs within Track */}
|
||||
{track.stages.length > 0 ? (
|
||||
<Tabs
|
||||
value={
|
||||
selectedTrackId === track.id
|
||||
? selectedStageId ?? undefined
|
||||
: undefined
|
||||
}
|
||||
onValueChange={setSelectedStageId}
|
||||
>
|
||||
<TabsList className="w-full justify-start overflow-x-auto">
|
||||
{track.stages
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((stage) => (
|
||||
<TabsTrigger
|
||||
key={stage.id}
|
||||
value={stage.id}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<span>{stage.name}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[9px] h-4 px-1',
|
||||
stageTypeColors[stage.stageType] ?? ''
|
||||
)}
|
||||
>
|
||||
{stage.stageType.replace('_', ' ')}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{track.stages.map((stage) => (
|
||||
<TabsContent
|
||||
key={stage.id}
|
||||
value={stage.id}
|
||||
className="mt-4"
|
||||
>
|
||||
<StagePanel
|
||||
stageId={stage.id}
|
||||
stageType={stage.stageType}
|
||||
configJson={
|
||||
stage.configJson as Record<string, unknown> | null
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No stages configured for this track
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user