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

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
@ -34,27 +34,17 @@ import {
import { InlineEditableText } from '@/components/ui/inline-editable-text' import { InlineEditableText } from '@/components/ui/inline-editable-text'
import { PipelineFlowchart } from '@/components/admin/pipeline/pipeline-flowchart' import { PipelineFlowchart } from '@/components/admin/pipeline/pipeline-flowchart'
import { StageConfigEditor } from '@/components/admin/pipeline/stage-config-editor' import { StageDetailSheet } from '@/components/admin/pipeline/stage-detail-sheet'
import { usePipelineInlineEdit } from '@/hooks/use-pipeline-inline-edit' import { usePipelineInlineEdit } from '@/hooks/use-pipeline-inline-edit'
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section' import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section' import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section' import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
import { FileRequirementsEditor } from '@/components/admin/file-requirements-editor'
import { FilteringRulesEditor } from '@/components/admin/pipeline/filtering-rules-editor'
import { RoutingRulesEditor } from '@/components/admin/pipeline/routing-rules-editor' import { RoutingRulesEditor } from '@/components/admin/pipeline/routing-rules-editor'
import { StageTransitionsEditor } from '@/components/admin/pipeline/stage-transitions-editor'
import { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor' import { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor'
import { normalizeStageConfig } from '@/lib/stage-config-schema' import { normalizeStageConfig } from '@/lib/stage-config-schema'
import { defaultNotificationConfig } from '@/lib/pipeline-defaults' import { defaultNotificationConfig } from '@/lib/pipeline-defaults'
import type { WizardTrackConfig } from '@/types/pipeline-wizard' import type { WizardTrackConfig } from '@/types/pipeline-wizard'
import { IntakePanel } from '@/components/admin/pipeline/stage-panels/intake-panel'
import { FilterPanel } from '@/components/admin/pipeline/stage-panels/filter-panel'
import { EvaluationPanel } from '@/components/admin/pipeline/stage-panels/evaluation-panel'
import { SelectionPanel } from '@/components/admin/pipeline/stage-panels/selection-panel'
import { LiveFinalPanel } from '@/components/admin/pipeline/stage-panels/live-final-panel'
import { ResultsPanel } from '@/components/admin/pipeline/stage-panels/results-panel'
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700', DRAFT: 'bg-gray-100 text-gray-700',
ACTIVE: 'bg-emerald-100 text-emerald-700', ACTIVE: 'bg-emerald-100 text-emerald-700',
@ -62,39 +52,6 @@ const statusColors: Record<string, string> = {
CLOSED: 'bg-blue-100 text-blue-700', CLOSED: 'bg-blue-100 text-blue-700',
} }
function StagePanel({
stageId,
stageType,
configJson,
}: {
stageId: string
stageType: string
configJson: Record<string, unknown> | null
}) {
switch (stageType) {
case 'INTAKE':
return <IntakePanel stageId={stageId} configJson={configJson} />
case 'FILTER':
return <FilterPanel stageId={stageId} configJson={configJson} />
case 'EVALUATION':
return <EvaluationPanel stageId={stageId} configJson={configJson} />
case 'SELECTION':
return <SelectionPanel stageId={stageId} configJson={configJson} />
case 'LIVE_FINAL':
return <LiveFinalPanel stageId={stageId} configJson={configJson} />
case 'RESULTS':
return <ResultsPanel stageId={stageId} configJson={configJson} />
default:
return (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
Unknown stage type: {stageType}
</CardContent>
</Card>
)
}
}
function toWizardTrackConfig( function toWizardTrackConfig(
track: { track: {
id: string id: string
@ -167,6 +124,7 @@ export default function PipelineDetailPage() {
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null) const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
const [selectedStageId, setSelectedStageId] = useState<string | null>(null) const [selectedStageId, setSelectedStageId] = useState<string | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [structureTracks, setStructureTracks] = useState<WizardTrackConfig[]>([]) const [structureTracks, setStructureTracks] = useState<WizardTrackConfig[]>([])
const [notificationConfig, setNotificationConfig] = useState<Record<string, boolean>>({}) const [notificationConfig, setNotificationConfig] = useState<Record<string, boolean>>({})
const [overridePolicy, setOverridePolicy] = useState<Record<string, unknown>>({ const [overridePolicy, setOverridePolicy] = useState<Record<string, unknown>>({
@ -175,8 +133,6 @@ export default function PipelineDetailPage() {
const [structureDirty, setStructureDirty] = useState(false) const [structureDirty, setStructureDirty] = useState(false)
const [settingsDirty, setSettingsDirty] = useState(false) const [settingsDirty, setSettingsDirty] = useState(false)
const stagePanelRef = useRef<HTMLDivElement>(null)
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({ const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
id: pipelineId, id: pipelineId,
}) })
@ -220,15 +176,11 @@ export default function PipelineDetailPage() {
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
// Auto-select first track and stage on load // Auto-select first track on load
useEffect(() => { useEffect(() => {
if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) { if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) {
const firstTrack = pipeline.tracks.sort((a, b) => a.sortOrder - b.sortOrder)[0] const firstTrack = pipeline.tracks.sort((a, b) => a.sortOrder - b.sortOrder)[0]
setSelectedTrackId(firstTrack.id) setSelectedTrackId(firstTrack.id)
if (firstTrack.stages.length > 0) {
const firstStage = firstTrack.stages.sort((a, b) => a.sortOrder - b.sortOrder)[0]
setSelectedStageId(firstStage.id)
}
} }
}, [pipeline, selectedTrackId]) }, [pipeline, selectedTrackId])
@ -280,13 +232,6 @@ export default function PipelineDetailPage() {
setSettingsDirty(false) setSettingsDirty(false)
}, [pipeline]) }, [pipeline])
// Scroll to stage panel when a stage is selected
useEffect(() => {
if (selectedStageId && stagePanelRef.current) {
stagePanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, [selectedStageId])
const trackOptionsForEditors = useMemo( const trackOptionsForEditors = useMemo(
() => () =>
(pipeline?.tracks ?? []) (pipeline?.tracks ?? [])
@ -348,20 +293,17 @@ export default function PipelineDetailPage() {
(s) => s.id === selectedStageId (s) => s.id === selectedStageId
) )
const mainTrackDraft = structureTracks.find((track) => track.kind === 'MAIN') 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) => { const handleTrackChange = (trackId: string) => {
setSelectedTrackId(trackId) setSelectedTrackId(trackId)
const track = pipeline.tracks.find((t) => t.id === trackId)
if (track && track.stages.length > 0) {
const firstStage = track.stages.sort((a, b) => a.sortOrder - b.sortOrder)[0]
setSelectedStageId(firstStage.id)
} else {
setSelectedStageId(null) setSelectedStageId(null)
} }
}
const handleStageSelect = (stageId: string) => { const handleStageSelect = (stageId: string) => {
setSelectedStageId(stageId) setSelectedStageId(stageId)
setSheetOpen(true)
} }
const handleStatusChange = async (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => { const handleStatusChange = async (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => {
@ -426,7 +368,7 @@ export default function PipelineDetailPage() {
const flowchartTracks = selectedTrack ? [selectedTrack] : [] const flowchartTracks = selectedTrack ? [selectedTrack] : []
return ( return (
<div className="space-y-6"> <div className="space-y-8">
{/* Header */} {/* Header */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
@ -593,7 +535,7 @@ export default function PipelineDetailPage() {
</div> </div>
{/* Track Switcher (only if multiple tracks) */} {/* Track Switcher (only if multiple tracks) */}
{pipeline.tracks.length > 1 && ( {hasMultipleTracks && (
<div className="flex items-center gap-2 flex-wrap overflow-x-auto pb-1"> <div className="flex items-center gap-2 flex-wrap overflow-x-auto pb-1">
{pipeline.tracks {pipeline.tracks
.sort((a, b) => a.sortOrder - b.sortOrder) .sort((a, b) => a.sortOrder - b.sortOrder)
@ -627,11 +569,16 @@ export default function PipelineDetailPage() {
{/* Pipeline Flowchart */} {/* Pipeline Flowchart */}
{flowchartTracks.length > 0 ? ( {flowchartTracks.length > 0 ? (
<div>
<PipelineFlowchart <PipelineFlowchart
tracks={flowchartTracks} tracks={flowchartTracks}
selectedStageId={selectedStageId} selectedStageId={selectedStageId}
onStageSelect={handleStageSelect} onStageSelect={handleStageSelect}
/> />
<p className="text-xs text-muted-foreground mt-2">
Click a stage to edit its configuration
</p>
</div>
) : ( ) : (
<Card> <Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground"> <CardContent className="py-8 text-center text-sm text-muted-foreground">
@ -640,118 +587,39 @@ export default function PipelineDetailPage() {
</Card> </Card>
)} )}
{/* Selected Stage Detail */} {/* Stage Detail Sheet */}
<div ref={stagePanelRef}> <StageDetailSheet
{selectedStage ? ( open={sheetOpen}
<div className="space-y-4"> onOpenChange={setSheetOpen}
<div className="border-t pt-4"> stage={
<h2 className="text-lg font-semibold text-muted-foreground"> selectedStage
Selected Stage: <span className="text-foreground">{selectedStage.name}</span>
</h2>
</div>
{/* Stage Config Editor */}
<StageConfigEditor
stageId={selectedStage.id}
stageName={selectedStage.name}
stageType={selectedStage.stageType}
configJson={selectedStage.configJson as Record<string, unknown> | null}
onSave={updateStageConfig}
isSaving={isUpdating}
/>
{selectedStage.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>
<Button
type="button"
size="sm"
variant="outline"
onClick={() =>
materializeRequirementsMutation.mutate({
stageId: selectedStage.id,
})
}
disabled={materializeRequirementsMutation.isPending}
>
{materializeRequirementsMutation.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
Import Legacy Requirements
</Button>
</div>
<FileRequirementsEditor stageId={selectedStage.id} />
</div>
)}
{selectedStage.stageType === 'FILTER' && (
<FilteringRulesEditor stageId={selectedStage.id} />
)}
{/* Stage Activity Panel */}
<StagePanel
stageId={selectedStage.id}
stageType={selectedStage.stageType}
configJson={selectedStage.configJson as Record<string, unknown> | null}
/>
{selectedTrack && (
<StageTransitionsEditor
trackId={selectedTrack.id}
stages={selectedTrack.stages.map((stage) => ({
id: stage.id,
name: stage.name,
sortOrder: stage.sortOrder,
}))}
/>
)}
</div>
) : (
<Card>
<CardContent className="py-12 text-center">
<p className="text-sm text-muted-foreground">
Click a stage in the flowchart above to view its configuration and activity
</p>
</CardContent>
</Card>
)}
</div>
<RoutingRulesEditor
pipelineId={pipelineId}
tracks={trackOptionsForEditors}
/>
<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, id: selectedStage.id,
name: track.specialAward.name, name: selectedStage.name,
description: track.specialAward.description, stageType: selectedStage.stageType,
criteriaText: track.specialAward.criteriaText, configJson: selectedStage.configJson as Record<string, unknown> | null,
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, : 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> <Card>
<CardContent className="pt-4 space-y-6"> <CardContent className="pt-4 space-y-6">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold">Pipeline Configuration</h2> <h3 className="text-sm font-semibold">Pipeline Structure</h3>
<Button <Button
type="button" type="button"
size="sm" size="sm"
@ -787,11 +655,63 @@ export default function PipelineDetailPage() {
/> />
</CardContent> </CardContent>
</Card> </Card>
</div>
{/* 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}
/>
</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> <Card>
<CardContent className="pt-4 space-y-4"> <CardContent className="pt-4 space-y-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold">Notifications and Overrides</h2> <h3 className="text-sm font-semibold">Notifications and Overrides</h3>
<Button <Button
type="button" type="button"
size="sm" size="sm"
@ -821,5 +741,6 @@ export default function PipelineDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div>
) )
} }

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"
value={draft.predicateText}
onChange={(e) =>
setDrafts((prev) => ({ setDrafts((prev) => ({
...prev, ...prev,
[rule.id]: { ...draft, predicateText: e.target.value }, [rule.id]: { ...draft, predicateJson: predicate },
})) }))
} }
/> />
</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

@ -3,6 +3,8 @@
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,
@ -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> = {
@ -255,6 +258,7 @@ export function StageConfigEditor({
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 ?? {}
@ -375,6 +379,29 @@ export function StageConfigEditor({
} }
} }
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 (
<EditableCard <EditableCard
title={`${stageName} Configuration`} title={`${stageName} Configuration`}

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,
}