From c321d4711e001df8841aa5909b11b887ad72aa24 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 14 Feb 2026 18:11:48 +0100 Subject: [PATCH] Improve pipeline editor UX: stage detail sheet, structured predicates, page reorganization - 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 --- .../admin/rounds/pipeline/[id]/page.tsx | 979 ++++++++---------- .../admin/pipeline/predicate-builder.tsx | 196 ++++ .../admin/pipeline/routing-rules-editor.tsx | 38 +- .../admin/pipeline/stage-config-editor.tsx | 147 +-- .../admin/pipeline/stage-detail-sheet.tsx | 178 ++++ src/components/ui/sheet.tsx | 135 +++ 6 files changed, 1059 insertions(+), 614 deletions(-) create mode 100644 src/components/admin/pipeline/predicate-builder.tsx create mode 100644 src/components/admin/pipeline/stage-detail-sheet.tsx create mode 100644 src/components/ui/sheet.tsx diff --git a/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx b/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx index e5b54e4..c07db43 100644 --- a/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx +++ b/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx @@ -1,26 +1,26 @@ 'use client' -import { useState, useEffect, useRef, useMemo } from 'react' +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 { +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 { ArrowLeft, MoreHorizontal, Rocket, @@ -34,65 +34,22 @@ import { import { InlineEditableText } from '@/components/ui/inline-editable-text' 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 { 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 { 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 { StageTransitionsEditor } from '@/components/admin/pipeline/stage-transitions-editor' import { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor' import { normalizeStageConfig } from '@/lib/stage-config-schema' import { defaultNotificationConfig } from '@/lib/pipeline-defaults' 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 = { - 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', -} - -function StagePanel({ - stageId, - stageType, - configJson, -}: { - stageId: string - stageType: string - configJson: Record | null -}) { - switch (stageType) { - case 'INTAKE': - return - case 'FILTER': - return - case 'EVALUATION': - return - case 'SELECTION': - return - case 'LIVE_FINAL': - return - case 'RESULTS': - return - default: - return ( - - - Unknown stage type: {stageType} - - - ) - } +const statusColors: Record = { + 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', } function toWizardTrackConfig( @@ -159,7 +116,7 @@ function toWizardTrackConfig( : undefined, } } - + export default function PipelineDetailPage() { const params = useParams() const pipelineId = params.id as string @@ -167,6 +124,7 @@ export default function PipelineDetailPage() { const [selectedTrackId, setSelectedTrackId] = useState(null) const [selectedStageId, setSelectedStageId] = useState(null) + const [sheetOpen, setSheetOpen] = useState(false) const [structureTracks, setStructureTracks] = useState([]) const [notificationConfig, setNotificationConfig] = useState>({}) const [overridePolicy, setOverridePolicy] = useState>({ @@ -175,20 +133,18 @@ export default function PipelineDetailPage() { const [structureDirty, setStructureDirty] = useState(false) const [settingsDirty, setSettingsDirty] = useState(false) - const stagePanelRef = useRef(null) - - 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 { 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), @@ -219,17 +175,13 @@ export default function PipelineDetailPage() { }, onError: (err) => toast.error(err.message), }) - - // Auto-select first track and stage on load + + // 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) - if (firstTrack.stages.length > 0) { - const firstStage = firstTrack.stages.sort((a, b) => a.sortOrder - b.sortOrder)[0] - setSelectedStageId(firstStage.id) - } - } + setSelectedTrackId(firstTrack.id) + } }, [pipeline, selectedTrackId]) useEffect(() => { @@ -279,13 +231,6 @@ export default function PipelineDetailPage() { setStructureDirty(false) setSettingsDirty(false) }, [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( () => @@ -308,62 +253,59 @@ export default function PipelineDetailPage() { ) if (isLoading) { - return ( -
-
- -
- - -
-
- - -
- ) - } - - if (!pipeline) { - return ( -
-
- - - -
-

Pipeline Not Found

-

- The requested pipeline does not exist -

-
-
-
- ) - } - + return ( +
+
+ +
+ + +
+
+ + +
+ ) + } + + if (!pipeline) { + return ( +
+
+ + + +
+

Pipeline Not Found

+

+ The requested pipeline does not exist +

+
+
+
+ ) + } + 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) - 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) - } - } - - const handleStageSelect = (stageId: string) => { - setSelectedStageId(stageId) - } - + setSelectedStageId(null) + } + + const handleStageSelect = (stageId: string) => { + setSelectedStageId(stageId) + setSheetOpen(true) + } + const handleStatusChange = async (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => { await updateMutation.mutateAsync({ id: pipelineId, @@ -424,402 +366,381 @@ export default function PipelineDetailPage() { // Prepare flowchart data for the selected track const flowchartTracks = selectedTrack ? [selectedTrack] : [] - - return ( -
- {/* Header */} -
-
-
- - - -
-
- updatePipeline({ name: newName })} - variant="h1" - placeholder="Untitled Pipeline" - disabled={isUpdating} - /> - - - - - - handleStatusChange('DRAFT')} - disabled={pipeline.status === 'DRAFT' || updateMutation.isPending} - > - Draft - - handleStatusChange('ACTIVE')} - disabled={pipeline.status === 'ACTIVE' || updateMutation.isPending} - > - Active - - handleStatusChange('CLOSED')} - disabled={pipeline.status === 'CLOSED' || updateMutation.isPending} - > - Closed - - - handleStatusChange('ARCHIVED')} - disabled={pipeline.status === 'ARCHIVED' || updateMutation.isPending} - > - Archived - - - -
-
- slug: - updatePipeline({ slug: newSlug })} - variant="mono" - placeholder="pipeline-slug" - disabled={isUpdating} - /> -
-
-
- + + return ( +
+ {/* Header */} +
+
+
+ + + +
+
+ updatePipeline({ name: newName })} + variant="h1" + placeholder="Untitled Pipeline" + disabled={isUpdating} + /> + + + + + + handleStatusChange('DRAFT')} + disabled={pipeline.status === 'DRAFT' || updateMutation.isPending} + > + Draft + + handleStatusChange('ACTIVE')} + disabled={pipeline.status === 'ACTIVE' || updateMutation.isPending} + > + Active + + handleStatusChange('CLOSED')} + disabled={pipeline.status === 'CLOSED' || updateMutation.isPending} + > + Closed + + + handleStatusChange('ARCHIVED')} + disabled={pipeline.status === 'ARCHIVED' || updateMutation.isPending} + > + Archived + + + +
+
+ slug: + updatePipeline({ slug: newSlug })} + variant="mono" + placeholder="pipeline-slug" + disabled={isUpdating} + /> +
+
+
+
- - - - - {pipeline.status === 'DRAFT' && ( - publishMutation.mutate({ id: pipelineId })} - > - {publishMutation.isPending ? ( - - ) : ( - - )} - Publish - - )} - {pipeline.status === 'ACTIVE' && ( - handleStatusChange('CLOSED')} - > - Close Pipeline - - )} - - handleStatusChange('ARCHIVED')} - > - - Archive - - - -
-
-
- - {/* Pipeline Summary */} -
- - -
- - Tracks -
-

{pipeline.tracks.length}

-

- {pipeline.tracks.filter((t) => t.kind === 'MAIN').length} main,{' '} - {pipeline.tracks.filter((t) => t.kind === 'AWARD').length} award -

-
-
- - -
- - Stages -
-

- {pipeline.tracks.reduce((sum, t) => sum + t.stages.length, 0)} -

-

across all tracks

-
-
- - -
- - Transitions -
-

- {pipeline.tracks.reduce( - (sum, t) => - sum + - t.stages.reduce( - (s, stage) => s + stage.transitionsFrom.length, - 0 - ), - 0 - )} -

-

stage connections

-
-
-
- - {/* Track Switcher (only if multiple tracks) */} - {pipeline.tracks.length > 1 && ( -
- {pipeline.tracks - .sort((a, b) => a.sortOrder - b.sortOrder) - .map((track) => ( - - ))} -
- )} - - {/* Pipeline Flowchart */} - {flowchartTracks.length > 0 ? ( - - ) : ( - - - No tracks configured for this pipeline - - - )} - - {/* Selected Stage Detail */} -
- {selectedStage ? ( -
-
-

- Selected Stage: {selectedStage.name} -

-
- - {/* Stage Config Editor */} - | null} - onSave={updateStageConfig} - isSaving={isUpdating} - /> - - {selectedStage.stageType === 'INTAKE' && ( -
-
-

Intake File Requirements

- + + + {pipeline.status === 'DRAFT' && ( + publishMutation.mutate({ id: pipelineId })} > - {materializeRequirementsMutation.isPending && ( - + {publishMutation.isPending ? ( + + ) : ( + )} - Import Legacy Requirements - -
- -
- )} - - {selectedStage.stageType === 'FILTER' && ( - - )} - - {/* Stage Activity Panel */} - | null} - /> - - {selectedTrack && ( - ({ - id: stage.id, - name: stage.name, - sortOrder: stage.sortOrder, - }))} - /> - )} + Publish + + )} + {pipeline.status === 'ACTIVE' && ( + handleStatusChange('CLOSED')} + > + Close Pipeline + + )} + + handleStatusChange('ARCHIVED')} + > + + Archive + + +
- ) : ( - - -

- Click a stage in the flowchart above to view its configuration and activity -

-
-
- )} +
- - - 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, - }))} - /> - - - -
-

Pipeline Configuration

- -
- - {mainTrackDraft ? ( - - ) : ( -

- No main track configured. + {/* Pipeline Summary */} +

+ + +
+ + Tracks +
+

{pipeline.tracks.length}

+

+ {pipeline.tracks.filter((t) => t.kind === 'MAIN').length} main,{' '} + {pipeline.tracks.filter((t) => t.kind === 'AWARD').length} award

- )} - - { - setStructureTracks(tracks) - setStructureDirty(true) - }} - /> -
-
- - - -
-

Notifications and Overrides

- -
- { - setNotificationConfig(next) - setSettingsDirty(true) - }} - overridePolicy={overridePolicy} - onOverridePolicyChange={(next) => { - setOverridePolicy(next) - setSettingsDirty(true) - }} +

+

stage connections

+
+
+
+ + {/* Track Switcher (only if multiple tracks) */} + {hasMultipleTracks && ( +
+ {pipeline.tracks + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((track) => ( + + ))} +
+ )} + + {/* Pipeline Flowchart */} + {flowchartTracks.length > 0 ? ( +
+ - - +

+ Click a stage to edit its configuration +

+
+ ) : ( + + + No tracks configured for this pipeline + + + )} + + {/* Stage Detail Sheet */} + | null, + } + : null + } + onSaveConfig={updateStageConfig} + isSaving={isUpdating} + pipelineId={pipelineId} + materializeRequirements={(stageId) => + materializeRequirementsMutation.mutate({ stageId }) + } + isMaterializing={materializeRequirementsMutation.isPending} + /> + + {/* Stage Management */} +
+

Stage Management

+

+ Add, remove, reorder, or change stage types. Click a stage in the flowchart to edit its settings. +

+ + +
+

Pipeline Structure

+ +
+ + {mainTrackDraft ? ( + + ) : ( +

+ No main track configured. +

+ )} + + { + setStructureTracks(tracks) + setStructureDirty(true) + }} + /> +
+
+
+ + {/* Routing Rules (only if multiple tracks) */} + {hasMultipleTracks && ( +
+

Routing Rules

+

+ Define conditions for routing projects between tracks. +

+ +
+ )} + + {/* Award Governance (only if award tracks exist) */} + {hasAwardTracks && ( +
+

Award Governance

+

+ Configure special awards, voting, and scoring for award tracks. +

+ 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, + }))} + /> +
+ )} + + {/* Settings */} +
+

Settings

+ + +
+

Notifications and Overrides

+ +
+ { + setNotificationConfig(next) + setSettingsDirty(true) + }} + overridePolicy={overridePolicy} + onOverridePolicyChange={(next) => { + setOverridePolicy(next) + setSettingsDirty(true) + }} + /> +
+
+
) } diff --git a/src/components/admin/pipeline/predicate-builder.tsx b/src/components/admin/pipeline/predicate-builder.tsx new file mode 100644 index 0000000..0e11e52 --- /dev/null +++ b/src/components/admin/pipeline/predicate-builder.tsx @@ -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 + onChange: (predicate: Record) => void +} + +function isSimplePredicate(obj: Record): obj is SimplePredicate { + return ( + typeof obj.field === 'string' && + typeof obj.operator === 'string' && + (typeof obj.value === 'string' || typeof obj.value === 'boolean') + ) +} + +function isCompound(obj: Record): 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 ( +
+
+
+ + {compound && ( + + Complex condition + + )} +
+ {!compound && ( + + )} +
+