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>
687 lines
24 KiB
TypeScript
687 lines
24 KiB
TypeScript
'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 { RoutingRulesEditor } from '@/components/admin/pipeline/routing-rules-editor'
|
|
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>
|
|
|
|
{/* Routing Rules (only if multiple tracks) */}
|
|
{hasMultipleTracks && (
|
|
<div>
|
|
<RoutingRulesEditor
|
|
pipelineId={pipelineId}
|
|
tracks={trackOptionsForEditors}
|
|
/>
|
|
</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>
|
|
)
|
|
}
|