Pipeline UX: clickable cards, wizard edit, routing rules redesign, category quotas
All checks were successful
Build and Push Docker Image / build (push) Successful in 18s

- Simplify pipeline list cards: whole card is clickable, remove clutter
- Add wizard edit page for existing pipelines with full state pre-population
- Extract toWizardTrackConfig to shared utility for reuse
- Rewrite predicate builder with 3 modes: Simple (sentence-style), AI (NLP), Advanced (JSON)
- Fix routing operators to match backend (eq/neq/in/contains/gt/lt)
- Rewrite routing rules editor with collapsible cards and natural language summaries
- Add parseNaturalLanguageRule AI procedure for routing rules
- Add per-category quotas to SelectionConfig and EvaluationConfig
- Add category quota UI toggles to selection and assignment sections
- Add category breakdown display to selection panel
- Add category-aware scoring to smart assignment (penalty/bonus)
- Add category-aware filtering targets with excess demotion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-14 20:10:24 +01:00
parent c634982835
commit 382570cebd
17 changed files with 2577 additions and 1095 deletions

View File

@@ -20,6 +20,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import type { Route } from 'next'
import {
ArrowLeft,
MoreHorizontal,
@@ -30,6 +31,7 @@ import {
Loader2,
ChevronDown,
Save,
Wand2,
} from 'lucide-react'
import { InlineEditableText } from '@/components/ui/inline-editable-text'
@@ -41,8 +43,8 @@ import { AwardsSection } from '@/components/admin/pipeline/sections/awards-secti
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
import { RoutingRulesEditor } from '@/components/admin/pipeline/routing-rules-editor'
import { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor'
import { normalizeStageConfig } from '@/lib/stage-config-schema'
import { defaultNotificationConfig } from '@/lib/pipeline-defaults'
import { toWizardTrackConfig } from '@/lib/pipeline-conversions'
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
const statusColors: Record<string, string> = {
@@ -52,71 +54,6 @@ const statusColors: Record<string, string> = {
CLOSED: 'bg-blue-100 text-blue-700',
}
function toWizardTrackConfig(
track: {
id: string
name: string
slug: string
kind: 'MAIN' | 'AWARD' | 'SHOWCASE'
sortOrder: number
routingMode: 'PARALLEL' | 'EXCLUSIVE' | 'POST_MAIN' | null
decisionMode:
| 'JURY_VOTE'
| 'AWARD_MASTER_DECISION'
| 'ADMIN_DECISION'
| null
stages: Array<{
id: string
name: string
slug: string
stageType:
| 'INTAKE'
| 'FILTER'
| 'EVALUATION'
| 'SELECTION'
| 'LIVE_FINAL'
| 'RESULTS'
sortOrder: number
configJson: unknown
}>
specialAward?: {
name: string
description: string | null
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
} | null
}
): WizardTrackConfig {
return {
id: track.id,
name: track.name,
slug: track.slug,
kind: track.kind,
sortOrder: track.sortOrder,
routingModeDefault: track.routingMode ?? undefined,
decisionMode: track.decisionMode ?? undefined,
stages: track.stages
.map((stage) => ({
id: stage.id,
name: stage.name,
slug: stage.slug,
stageType: stage.stageType,
sortOrder: stage.sortOrder,
configJson: normalizeStageConfig(
stage.stageType,
stage.configJson as Record<string, unknown> | null
),
}))
.sort((a, b) => a.sortOrder - b.sortOrder),
awardConfig: track.specialAward
? {
name: track.specialAward.name,
description: track.specialAward.description ?? undefined,
scoringMode: track.specialAward.scoringMode,
}
: undefined,
}
}
export default function PipelineDetailPage() {
const params = useParams()
const pipelineId = params.id as string
@@ -450,6 +387,13 @@ export default function PipelineDetailPage() {
</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}
@@ -660,10 +604,6 @@ export default function PipelineDetailPage() {
{/* Routing Rules (only if multiple tracks) */}
{hasMultipleTracks && (
<div>
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Routing Rules</h2>
<p className="text-sm text-muted-foreground mb-4">
Define conditions for routing projects between tracks.
</p>
<RoutingRulesEditor
pipelineId={pipelineId}
tracks={trackOptionsForEditors}

View File

@@ -0,0 +1,410 @@
'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 'PARALLEL' | 'EXCLUSIVE' | 'POST_MAIN' | 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

@@ -12,14 +12,12 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
Plus,
Layers,
GitBranch,
Calendar,
Workflow,
Pencil,
} from 'lucide-react'
import {
Plus,
Layers,
Calendar,
Workflow,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import { useEdition } from '@/contexts/edition-context'
@@ -148,19 +146,15 @@ export default function PipelineListPage() {
const config = statusConfig[status] || statusConfig.DRAFT
const description = (pipeline.settingsJson as Record<string, unknown> | null)?.description as string | undefined
return (
<Card key={pipeline.id} className="group 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 mb-1">
<Link href={`/admin/rounds/pipeline/${pipeline.id}` as Route} className="hover:underline">
{pipeline.name}
</Link>
</CardTitle>
<p className="font-mono text-xs text-muted-foreground truncate">
{pipeline.slug}
</p>
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"
@@ -174,7 +168,6 @@ export default function PipelineListPage() {
</Badge>
</div>
{/* Description */}
{description && (
<p className="text-xs text-muted-foreground line-clamp-2 mt-2">
{description}
@@ -182,10 +175,9 @@ export default function PipelineListPage() {
)}
</CardHeader>
<CardContent className="mt-auto">
{/* Track Indicator - Simplified visualization */}
<div className="mb-3 pb-3 border-b">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1.5">
<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
@@ -195,56 +187,15 @@ export default function PipelineListPage() {
: `${pipeline._count.tracks} tracks`}
</span>
</div>
{pipeline._count.tracks > 0 && (
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(pipeline._count.tracks, 5) }).map((_, i) => (
<div
key={i}
className="h-6 flex-1 rounded border border-border bg-muted/30 flex items-center justify-center"
>
<div className="h-1 w-1 rounded-full bg-muted-foreground/40" />
</div>
))}
{pipeline._count.tracks > 5 && (
<span className="text-[10px] text-muted-foreground ml-1">
+{pipeline._count.tracks - 5}
</span>
)}
</div>
)}
<span>Updated {formatDistanceToNow(new Date(pipeline.updatedAt))} ago</span>
</div>
{/* Stats */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<GitBranch className="h-3.5 w-3.5" />
<span>Routing rules</span>
</div>
<span className="font-medium text-foreground">
{pipeline._count.routingRules}
</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Updated {formatDistanceToNow(new Date(pipeline.updatedAt))} ago</span>
</div>
</div>
<div className="mt-3 flex items-center gap-2">
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route} className="w-full">
<Button size="sm" variant="outline" className="w-full">
<Pencil className="h-3.5 w-3.5 mr-1.5" />
Edit
</Button>
</Link>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</CardContent>
</Card>
</Link>
)
})}
</div>
)}
</div>
)
}