Improve pipeline editor UX: stage detail sheet, structured predicates, page reorganization
Build and Push Docker Image / build (push) Failing after 40s Details

- Add Sheet UI component and StageDetailSheet with config/activity tabs
- Stage config opens in right-side sheet (always-editable, no collapsed summary)
- Replace JSON textarea in routing rules with structured PredicateBuilder form
- Remove StageTransitionsEditor from UI (transitions auto-managed)
- Promote Stage Management section to immediately after flowchart
- Conditionally hide Routing Rules (single track) and Award Governance (no awards)
- Add section headers with descriptions and increase spacing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-14 18:11:48 +01:00
parent 2d91ce02fc
commit c321d4711e
6 changed files with 1059 additions and 614 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,196 @@
'use client'
import { useState, useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Code } from 'lucide-react'
const FIELD_OPTIONS = [
{ value: 'competitionCategory', label: 'Competition Category' },
{ value: 'oceanIssue', label: 'Ocean Issue' },
{ value: 'country', label: 'Country' },
{ value: 'geographicZone', label: 'Geographic Zone' },
{ value: 'wantsMentorship', label: 'Wants Mentorship' },
{ value: 'tags', label: 'Tags' },
] as const
const OPERATOR_OPTIONS = [
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
{ value: 'contains', label: 'contains' },
{ value: 'in', label: 'in' },
] as const
type SimplePredicate = {
field: string
operator: string
value: string
}
type PredicateBuilderProps = {
value: Record<string, unknown>
onChange: (predicate: Record<string, unknown>) => void
}
function isSimplePredicate(obj: Record<string, unknown>): obj is SimplePredicate {
return (
typeof obj.field === 'string' &&
typeof obj.operator === 'string' &&
(typeof obj.value === 'string' || typeof obj.value === 'boolean')
)
}
function isCompound(obj: Record<string, unknown>): boolean {
return 'or' in obj || 'and' in obj || 'not' in obj
}
export function PredicateBuilder({ value, onChange }: PredicateBuilderProps) {
const [jsonMode, setJsonMode] = useState(false)
const [jsonText, setJsonText] = useState('')
const compound = isCompound(value)
const simple = !compound && isSimplePredicate(value)
useEffect(() => {
if (compound) {
setJsonMode(true)
setJsonText(JSON.stringify(value, null, 2))
}
}, [compound, value])
if (jsonMode) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Label className="text-xs">Predicate (JSON)</Label>
{compound && (
<Badge variant="secondary" className="text-[10px]">
Complex condition
</Badge>
)}
</div>
{!compound && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={() => {
try {
const parsed = JSON.parse(jsonText) as Record<string, unknown>
onChange(parsed)
setJsonMode(false)
} catch {
// stay in JSON mode
}
}}
>
Switch to form
</Button>
)}
</div>
<Textarea
className="font-mono text-xs min-h-24"
value={jsonText}
onChange={(e) => {
setJsonText(e.target.value)
try {
const parsed = JSON.parse(e.target.value) as Record<string, unknown>
onChange(parsed)
} catch {
// don't update on invalid JSON
}
}}
/>
</div>
)
}
const predicate: SimplePredicate = simple
? { field: value.field as string, operator: value.operator as string, value: String(value.value) }
: { field: 'competitionCategory', operator: 'equals', value: '' }
const updateField = (field: string, val: string) => {
const next = { ...predicate, [field]: val }
onChange(next as unknown as Record<string, unknown>)
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">Condition</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-xs gap-1"
onClick={() => {
setJsonText(JSON.stringify(value, null, 2))
setJsonMode(true)
}}
>
<Code className="h-3 w-3" />
Edit as JSON
</Button>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">Field</Label>
<Select
value={predicate.field}
onValueChange={(v) => updateField('field', v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">Operator</Label>
<Select
value={predicate.operator}
onValueChange={(v) => updateField('operator', v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATOR_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">Value</Label>
<Input
className="h-8 text-xs"
value={predicate.value}
onChange={(e) => updateField('value', e.target.value)}
placeholder="e.g. STARTUP"
/>
</div>
</div>
</div>
)
}

View File

@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { PredicateBuilder } from '@/components/admin/pipeline/predicate-builder'
import { import {
Select, Select,
SelectContent, SelectContent,
@ -52,7 +52,7 @@ type RuleDraft = {
destinationStageId: string | null destinationStageId: string | null
priority: number priority: number
isActive: boolean isActive: boolean
predicateText: string predicateJson: Record<string, unknown>
} }
const DEFAULT_PREDICATE = { const DEFAULT_PREDICATE = {
@ -119,7 +119,7 @@ export function RoutingRulesEditor({
destinationStageId: rule.destinationStageId ?? null, destinationStageId: rule.destinationStageId ?? null,
priority: rule.priority, priority: rule.priority,
isActive: rule.isActive, isActive: rule.isActive,
predicateText: JSON.stringify(rule.predicateJson ?? {}, null, 2), predicateJson: (rule.predicateJson as Record<string, unknown>) ?? {},
} }
} }
setDrafts(nextDrafts) setDrafts(nextDrafts)
@ -148,14 +148,6 @@ export function RoutingRulesEditor({
const draft = drafts[id] const draft = drafts[id]
if (!draft) return if (!draft) return
let predicateJson: Record<string, unknown>
try {
predicateJson = JSON.parse(draft.predicateText) as Record<string, unknown>
} catch {
toast.error('Predicate must be valid JSON')
return
}
await upsertRule.mutateAsync({ await upsertRule.mutateAsync({
id: draft.id, id: draft.id,
pipelineId, pipelineId,
@ -166,7 +158,7 @@ export function RoutingRulesEditor({
destinationStageId: draft.destinationStageId, destinationStageId: draft.destinationStageId,
priority: draft.priority, priority: draft.priority,
isActive: draft.isActive, isActive: draft.isActive,
predicateJson, predicateJson: draft.predicateJson,
}) })
} }
@ -370,19 +362,15 @@ export function RoutingRulesEditor({
</div> </div>
</div> </div>
<div className="space-y-1"> <PredicateBuilder
<Label className="text-xs">Predicate (JSON)</Label> value={draft.predicateJson}
<Textarea onChange={(predicate) =>
className="font-mono text-xs min-h-24" setDrafts((prev) => ({
value={draft.predicateText} ...prev,
onChange={(e) => [rule.id]: { ...draft, predicateJson: predicate },
setDrafts((prev) => ({ }))
...prev, }
[rule.id]: { ...draft, predicateText: e.target.value }, />
}))
}
/>
</div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View File

@ -1,8 +1,10 @@
'use client' 'use client'
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect } from 'react'
import { EditableCard } from '@/components/ui/editable-card' import { EditableCard } from '@/components/ui/editable-card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { import {
Inbox, Inbox,
@ -13,30 +15,30 @@ import {
BarChart3, BarChart3,
} from 'lucide-react' } from 'lucide-react'
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section' import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section' import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section' import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section' import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
import { SelectionSection } from '@/components/admin/pipeline/sections/selection-section' import { SelectionSection } from '@/components/admin/pipeline/sections/selection-section'
import { ResultsSection } from '@/components/admin/pipeline/sections/results-section' import { ResultsSection } from '@/components/admin/pipeline/sections/results-section'
import { import {
defaultIntakeConfig, defaultIntakeConfig,
defaultFilterConfig, defaultFilterConfig,
defaultEvaluationConfig, defaultEvaluationConfig,
defaultLiveConfig, defaultLiveConfig,
defaultSelectionConfig, defaultSelectionConfig,
defaultResultsConfig, defaultResultsConfig,
} from '@/lib/pipeline-defaults' } from '@/lib/pipeline-defaults'
import type { import type {
IntakeConfig, IntakeConfig,
FilterConfig, FilterConfig,
EvaluationConfig, EvaluationConfig,
LiveFinalConfig, LiveFinalConfig,
SelectionConfig, SelectionConfig,
ResultsConfig, ResultsConfig,
} from '@/types/pipeline-wizard' } from '@/types/pipeline-wizard'
type StageConfigEditorProps = { type StageConfigEditorProps = {
stageId: string stageId: string
@ -45,6 +47,7 @@ type StageConfigEditorProps = {
configJson: Record<string, unknown> | null configJson: Record<string, unknown> | null
onSave: (stageId: string, configJson: Record<string, unknown>) => Promise<void> onSave: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
isSaving?: boolean isSaving?: boolean
alwaysEditable?: boolean
} }
const stageIcons: Record<string, React.ReactNode> = { const stageIcons: Record<string, React.ReactNode> = {
@ -248,21 +251,22 @@ function ConfigSummary({
} }
} }
export function StageConfigEditor({ export function StageConfigEditor({
stageId, stageId,
stageName, stageName,
stageType, stageType,
configJson, configJson,
onSave, onSave,
isSaving = false, isSaving = false,
alwaysEditable = false,
}: StageConfigEditorProps) { }: StageConfigEditorProps) {
const [localConfig, setLocalConfig] = useState<Record<string, unknown>>( const [localConfig, setLocalConfig] = useState<Record<string, unknown>>(
() => configJson ?? {} () => configJson ?? {}
) )
useEffect(() => { useEffect(() => {
setLocalConfig(configJson ?? {}) setLocalConfig(configJson ?? {})
}, [stageId, configJson]) }, [stageId, configJson])
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
await onSave(stageId, localConfig) await onSave(stageId, localConfig)
@ -350,29 +354,52 @@ export function StageConfigEditor({
/> />
) )
} }
case 'SELECTION': case 'SELECTION':
return ( return (
<SelectionSection <SelectionSection
config={{ config={{
...defaultSelectionConfig(), ...defaultSelectionConfig(),
...(localConfig as SelectionConfig), ...(localConfig as SelectionConfig),
}} }}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)} onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/> />
) )
case 'RESULTS': case 'RESULTS':
return ( return (
<ResultsSection <ResultsSection
config={{ config={{
...defaultResultsConfig(), ...defaultResultsConfig(),
...(localConfig as ResultsConfig), ...(localConfig as ResultsConfig),
}} }}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)} onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/> />
) )
default: default:
return null return null
} }
}
if (alwaysEditable) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
{stageIcons[stageType] && (
<span className="text-muted-foreground">{stageIcons[stageType]}</span>
)}
<h3 className="text-sm font-semibold">{stageName} Configuration</h3>
<Badge variant="outline" className="text-[10px]">
{stageType.replace('_', ' ')}
</Badge>
</div>
{renderEditor()}
<div className="flex justify-end pt-2 border-t">
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
Save Changes
</Button>
</div>
</div>
)
} }
return ( return (

View File

@ -0,0 +1,178 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Loader2 } from 'lucide-react'
import { StageConfigEditor } from '@/components/admin/pipeline/stage-config-editor'
import { FileRequirementsEditor } from '@/components/admin/file-requirements-editor'
import { FilteringRulesEditor } from '@/components/admin/pipeline/filtering-rules-editor'
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'
type StageType = 'INTAKE' | 'FILTER' | 'EVALUATION' | 'SELECTION' | 'LIVE_FINAL' | 'RESULTS'
type StageDetailSheetProps = {
open: boolean
onOpenChange: (open: boolean) => void
stage: {
id: string
name: string
stageType: StageType
configJson: Record<string, unknown> | null
} | null
onSaveConfig: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
isSaving: boolean
pipelineId: string
materializeRequirements?: (stageId: string) => void
isMaterializing?: boolean
}
function StagePanelContent({
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 (
<p className="text-sm text-muted-foreground py-4">
Unknown stage type: {stageType}
</p>
)
}
}
const stageTypeLabels: Record<string, string> = {
INTAKE: 'Intake',
FILTER: 'Filter',
EVALUATION: 'Evaluation',
SELECTION: 'Selection',
LIVE_FINAL: 'Live Final',
RESULTS: 'Results',
}
export function StageDetailSheet({
open,
onOpenChange,
stage,
onSaveConfig,
isSaving,
pipelineId: _pipelineId,
materializeRequirements,
isMaterializing = false,
}: StageDetailSheetProps) {
if (!stage) return null
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-full sm:w-[540px] lg:w-[640px] sm:max-w-[640px] overflow-y-auto p-0"
>
<div className="p-6 pb-0">
<SheetHeader>
<div className="flex items-center gap-2">
<SheetTitle className="text-base">{stage.name}</SheetTitle>
<Badge variant="outline" className="text-[10px]">
{stageTypeLabels[stage.stageType] ?? stage.stageType}
</Badge>
</div>
<SheetDescription>
Configure settings and view activity for this stage
</SheetDescription>
</SheetHeader>
</div>
<div className="px-6 pt-4 pb-6">
<Tabs defaultValue="configuration">
<TabsList className="w-full">
<TabsTrigger value="configuration" className="flex-1">
Configuration
</TabsTrigger>
<TabsTrigger value="activity" className="flex-1">
Activity
</TabsTrigger>
</TabsList>
<TabsContent value="configuration" className="space-y-4 mt-4">
<StageConfigEditor
stageId={stage.id}
stageName={stage.name}
stageType={stage.stageType}
configJson={stage.configJson}
onSave={onSaveConfig}
isSaving={isSaving}
alwaysEditable
/>
{stage.stageType === 'INTAKE' && (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">Intake File Requirements</h3>
{materializeRequirements && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => materializeRequirements(stage.id)}
disabled={isMaterializing}
>
{isMaterializing && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
Import Legacy Requirements
</Button>
)}
</div>
<FileRequirementsEditor stageId={stage.id} />
</div>
)}
{stage.stageType === 'FILTER' && (
<FilteringRulesEditor stageId={stage.id} />
)}
</TabsContent>
<TabsContent value="activity" className="mt-4">
<StagePanelContent
stageId={stage.id}
stageType={stage.stageType}
configJson={stage.configJson}
/>
</TabsContent>
</Tabs>
</div>
</SheetContent>
</Sheet>
)
}

135
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,135 @@
'use client'
import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: 'right',
},
}
)
type SheetContentProps = React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> &
VariantProps<typeof sheetVariants>
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
)
SheetHeader.displayName = 'SheetHeader'
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
SheetFooter.displayName = 'SheetFooter'
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}