Competition/Round architecture: full platform rewrite (Phases 1-9)
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:
2026-02-15 23:04:15 +01:00
parent 9ab4717f96
commit 6ca39c976b
349 changed files with 69938 additions and 28767 deletions

View File

@@ -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>
)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}