From 59f90ccc37eccfa7ecb753c62ee28b000a6b9d42 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 14 Feb 2026 01:54:56 +0100 Subject: [PATCH] Pipeline UI/UX redesign: inline editing, flowchart, sidebar stepper - Add InlineEditableText, EditableCard, SidebarStepper shared components - Add PipelineFlowchart (interactive SVG stage visualization) - Add StageConfigEditor and usePipelineInlineEdit hook - Redesign detail page: flowchart replaces nested tabs, inline editing - Redesign creation wizard: sidebar stepper replaces accordion sections - Enhance list page: status dots, track indicators, relative timestamps - Convert edit page to redirect (editing now inline on detail page) - Delete old WizardSection accordion component Co-Authored-By: Claude Opus 4.6 --- .../admin/rounds/new-pipeline/page.tsx | 225 ++++----- .../admin/rounds/pipeline/[id]/edit/page.tsx | 432 +----------------- .../admin/rounds/pipeline/[id]/page.tsx | 360 ++++++++------- .../(admin)/admin/rounds/pipelines/page.tsx | 223 +++++---- .../admin/pipeline/pipeline-flowchart.tsx | 270 +++++++++++ .../admin/pipeline/stage-config-editor.tsx | 334 ++++++++++++++ .../admin/pipeline/wizard-section.tsx | 92 ---- src/components/ui/editable-card.tsx | 121 +++++ src/components/ui/inline-editable-text.tsx | 179 ++++++++ src/components/ui/sidebar-stepper.tsx | 262 +++++++++++ src/hooks/use-pipeline-inline-edit.ts | 46 ++ 11 files changed, 1609 insertions(+), 935 deletions(-) create mode 100644 src/components/admin/pipeline/pipeline-flowchart.tsx create mode 100644 src/components/admin/pipeline/stage-config-editor.tsx delete mode 100644 src/components/admin/pipeline/wizard-section.tsx create mode 100644 src/components/ui/editable-card.tsx create mode 100644 src/components/ui/inline-editable-text.tsx create mode 100644 src/components/ui/sidebar-stepper.tsx create mode 100644 src/hooks/use-pipeline-inline-edit.ts diff --git a/src/app/(admin)/admin/rounds/new-pipeline/page.tsx b/src/app/(admin)/admin/rounds/new-pipeline/page.tsx index 0290878..73ca9d1 100644 --- a/src/app/(admin)/admin/rounds/new-pipeline/page.tsx +++ b/src/app/(admin)/admin/rounds/new-pipeline/page.tsx @@ -6,10 +6,11 @@ import type { Route } from 'next' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { Button } from '@/components/ui/button' -import { ArrowLeft, Loader2, Save, Rocket } from 'lucide-react' +import { ArrowLeft } from 'lucide-react' import Link from 'next/link' -import { WizardSection } from '@/components/admin/pipeline/wizard-section' +import { SidebarStepper } from '@/components/ui/sidebar-stepper' +import type { StepConfig } from '@/components/ui/sidebar-stepper' import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section' import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section' import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section' @@ -32,7 +33,7 @@ export default function NewPipelinePage() { const programId = searchParams.get('programId') || currentEdition?.id || '' const [state, setState] = useState(() => defaultWizardState(programId)) - const [openSection, setOpenSection] = useState(0) + const [currentStep, setCurrentStep] = useState(0) const initialStateRef = useRef(JSON.stringify(state)) // Update programId in state when edition context loads @@ -129,9 +130,9 @@ export default function NewPipelinePage() { const validation = validateAll(state) if (!validation.valid) { toast.error('Please fix validation errors before saving') - // Open first section with errors - if (!validation.sections.basics.valid) setOpenSection(0) - else if (!validation.sections.tracks.valid) setOpenSection(2) + // Navigate to first section with errors + if (!validation.sections.basics.valid) setCurrentStep(0) + else if (!validation.sections.tracks.valid) setCurrentStep(2) return } @@ -168,7 +169,8 @@ export default function NewPipelinePage() { } } - const isSaving = createMutation.isPending || publishMutation.isPending + const isSaving = createMutation.isPending && !publishMutation.isPending + const isSubmitting = publishMutation.isPending if (!programId) { return ( @@ -190,230 +192,161 @@ export default function NewPipelinePage() { ) } - const sections = [ + // Step configuration + const steps: StepConfig[] = [ { title: 'Basics', - description: 'Pipeline name, slug, and program', + description: 'Pipeline name and program', isValid: basicsValid, }, { title: 'Intake', - description: 'Submission windows and file requirements', + description: 'Submission window & files', isValid: !!intakeStage, }, { title: 'Main Track Stages', - description: `${mainTrack?.stages.length ?? 0} stages configured`, + description: 'Configure pipeline stages', isValid: tracksValid, }, { - title: 'Filtering', - description: 'Gate rules and AI screening settings', + title: 'Screening', + description: 'Gate rules and AI screening', isValid: !!filterStage, }, { - title: 'Assignment', - description: 'Jury evaluation assignment strategy', + title: 'Evaluation', + description: 'Jury assignment strategy', isValid: !!evalStage, }, { title: 'Awards', - description: `${state.tracks.filter((t) => t.kind === 'AWARD').length} award tracks`, + description: 'Special award tracks', isValid: true, // Awards are optional }, { title: 'Live Finals', - description: 'Voting, cohorts, and reveal settings', + description: 'Voting and reveal settings', isValid: !!liveStage, }, { title: 'Notifications', - description: 'Event notifications and override governance', + description: 'Event notifications', isValid: true, // Always valid }, { - title: 'Review & Publish', - description: 'Validation summary and publish controls', + title: 'Review & Create', + description: 'Validation summary', isValid: allValid, }, ] return ( -
+
{/* Header */} -
-
- - - -
-

Create Pipeline

-

- Configure the full pipeline structure for project evaluation -

-
-
-
- - + +
+

Create Pipeline

+

+ Configure the full pipeline structure for project evaluation +

- {/* Wizard Sections */} -
- {/* 0: Basics */} - setOpenSection(openSection === 0 ? -1 : 0)} - isValid={sections[0].isValid} - > + {/* Sidebar Stepper */} + handleSave(false)} + onSubmit={() => handleSave(true)} + isSaving={isSaving} + isSubmitting={isSubmitting} + saveLabel="Save Draft" + submitLabel="Save & Publish" + canSubmit={allValid} + > + {/* Step 0: Basics */} +
- +
- {/* 1: Intake */} - setOpenSection(openSection === 1 ? -1 : 1)} - isValid={sections[1].isValid} - > + {/* Step 1: Intake */} +
updateStageConfig('INTAKE', c as unknown as Record) } /> - +
- {/* 2: Main Track Stages */} - setOpenSection(openSection === 2 ? -1 : 2)} - isValid={sections[2].isValid} - > + {/* Step 2: Main Track Stages */} +
- +
- {/* 3: Filtering */} - setOpenSection(openSection === 3 ? -1 : 3)} - isValid={sections[3].isValid} - > + {/* Step 3: Screening */} +
updateStageConfig('FILTER', c as unknown as Record) } /> - +
- {/* 4: Assignment */} - setOpenSection(openSection === 4 ? -1 : 4)} - isValid={sections[4].isValid} - > + {/* Step 4: Evaluation */} +
updateStageConfig('EVALUATION', c as unknown as Record) } /> - +
- {/* 5: Awards */} - setOpenSection(openSection === 5 ? -1 : 5)} - isValid={sections[5].isValid} - > - updateState({ tracks })} /> - + {/* Step 5: Awards */} +
+ updateState({ tracks })} + /> +
- {/* 6: Live Finals */} - setOpenSection(openSection === 6 ? -1 : 6)} - isValid={sections[6].isValid} - > + {/* Step 6: Live Finals */} +
updateStageConfig('LIVE_FINAL', c as unknown as Record) } /> - +
- {/* 7: Notifications */} - setOpenSection(openSection === 7 ? -1 : 7)} - isValid={sections[7].isValid} - > + {/* Step 7: Notifications */} +
updateState({ notificationConfig })} overridePolicy={state.overridePolicy} onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })} /> - +
- {/* 8: Review */} - setOpenSection(openSection === 8 ? -1 : 8)} - isValid={sections[8].isValid} - > + {/* Step 8: Review & Create */} +
- -
+
+
) } diff --git a/src/app/(admin)/admin/rounds/pipeline/[id]/edit/page.tsx b/src/app/(admin)/admin/rounds/pipeline/[id]/edit/page.tsx index ceb5cf5..5017ebb 100644 --- a/src/app/(admin)/admin/rounds/pipeline/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/rounds/pipeline/[id]/edit/page.tsx @@ -1,429 +1,11 @@ -'use client' +import { redirect } from 'next/navigation' -import { useState, useCallback, useRef, useEffect } from 'react' -import { useRouter, useParams } from 'next/navigation' -import { trpc } from '@/lib/trpc/client' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { Skeleton } from '@/components/ui/skeleton' -import { ArrowLeft, Loader2, Save } from 'lucide-react' -import Link from 'next/link' -import type { Route } from 'next' - -import { WizardSection } from '@/components/admin/pipeline/wizard-section' -import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section' -import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section' -import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section' -import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section' -import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section' -import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section' -import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section' -import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section' -import { ReviewSection } from '@/components/admin/pipeline/sections/review-section' - -import { - defaultIntakeConfig, - defaultFilterConfig, - defaultEvaluationConfig, - defaultLiveConfig, - defaultNotificationConfig, -} from '@/lib/pipeline-defaults' -import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation' -import type { - WizardState, - IntakeConfig, - FilterConfig, - EvaluationConfig, - LiveFinalConfig, - WizardTrackConfig, -} from '@/types/pipeline-wizard' - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function pipelineToWizardState(pipeline: any): WizardState { - const settings = (pipeline.settingsJson as Record) ?? {} - - return { - name: pipeline.name, - slug: pipeline.slug, - programId: pipeline.programId, - settingsJson: settings, - tracks: (pipeline.tracks ?? []).map((t: any) => ({ - id: t.id, - name: t.name, - slug: t.slug, - kind: t.kind as WizardTrackConfig['kind'], - sortOrder: t.sortOrder, - routingModeDefault: t.routingMode as WizardTrackConfig['routingModeDefault'], - decisionMode: t.decisionMode as WizardTrackConfig['decisionMode'], - stages: (t.stages ?? []).map((s: any) => ({ - id: s.id, - name: s.name, - slug: s.slug, - stageType: s.stageType as WizardTrackConfig['stages'][0]['stageType'], - sortOrder: s.sortOrder, - configJson: (s.configJson as Record) ?? {}, - })), - awardConfig: t.specialAward - ? { - name: t.specialAward.name, - description: t.specialAward.description ?? undefined, - scoringMode: t.specialAward.scoringMode as NonNullable['scoringMode'], - } - : undefined, - })), - notificationConfig: - (settings.notificationConfig as Record) ?? - defaultNotificationConfig(), - overridePolicy: - (settings.overridePolicy as Record) ?? { - allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'], - }, - } +type EditPipelinePageProps = { + params: Promise<{ id: string }> } -export default function EditPipelinePage() { - const router = useRouter() - const params = useParams() - const pipelineId = params.id as string - - const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({ - id: pipelineId, - }) - - const [state, setState] = useState(null) - const [openSection, setOpenSection] = useState(0) - const initialStateRef = useRef('') - - // Initialize state from pipeline data - useEffect(() => { - if (pipeline && !state) { - const wizardState = pipelineToWizardState(pipeline) - setState(wizardState) - initialStateRef.current = JSON.stringify(wizardState) - } - }, [pipeline, state]) - - // Dirty tracking - useEffect(() => { - if (!state) return - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (JSON.stringify(state) !== initialStateRef.current) { - e.preventDefault() - } - } - window.addEventListener('beforeunload', handleBeforeUnload) - return () => window.removeEventListener('beforeunload', handleBeforeUnload) - }, [state]) - - const updateState = useCallback((updates: Partial) => { - setState((prev) => (prev ? { ...prev, ...updates } : null)) - }, []) - - const updateStageConfig = useCallback( - (stageType: string, configJson: Record) => { - setState((prev) => { - if (!prev) return null - return { - ...prev, - tracks: prev.tracks.map((track) => { - if (track.kind !== 'MAIN') return track - return { - ...track, - stages: track.stages.map((stage) => - stage.stageType === stageType ? { ...stage, configJson } : stage - ), - } - }), - } - }) - }, - [] - ) - - const updateMainTrackStages = useCallback( - (stages: WizardState['tracks'][0]['stages']) => { - setState((prev) => { - if (!prev) return null - return { - ...prev, - tracks: prev.tracks.map((track) => - track.kind === 'MAIN' ? { ...track, stages } : track - ), - } - }) - }, - [] - ) - - const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({ - onSuccess: () => { - if (state) initialStateRef.current = JSON.stringify(state) - toast.success('Pipeline updated successfully') - router.push(`/admin/rounds/pipeline/${pipelineId}` as Route) - }, - onError: (err) => toast.error(err.message), - }) - - if (isLoading || !state) { - return ( -
-
- -
- - -
-
- {[1, 2, 3].map((i) => ( - - ))} -
- ) - } - - const mainTrack = state.tracks.find((t) => t.kind === 'MAIN') - const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE') - const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER') - const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION') - const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL') - - const intakeConfig = { ...defaultIntakeConfig(), ...(intakeStage?.configJson ?? {}) } as IntakeConfig - const filterConfig = { ...defaultFilterConfig(), ...(filterStage?.configJson ?? {}) } as FilterConfig - const evalConfig = { ...defaultEvaluationConfig(), ...(evalStage?.configJson ?? {}) } as EvaluationConfig - const liveConfig = { ...defaultLiveConfig(), ...(liveStage?.configJson ?? {}) } as LiveFinalConfig - - const basicsValid = validateBasics(state).valid - const tracksValid = validateTracks(state.tracks).valid - const allValid = validateAll(state).valid - const isActive = pipeline?.status === 'ACTIVE' - - const handleSave = async () => { - const validation = validateAll(state) - if (!validation.valid) { - toast.error('Please fix validation errors before saving') - if (!validation.sections.basics.valid) setOpenSection(0) - else if (!validation.sections.tracks.valid) setOpenSection(2) - return - } - - await updateStructureMutation.mutateAsync({ - id: pipelineId, - name: state.name, - slug: state.slug, - settingsJson: { - ...state.settingsJson, - notificationConfig: state.notificationConfig, - overridePolicy: state.overridePolicy, - }, - tracks: state.tracks.map((t) => ({ - id: t.id, - name: t.name, - slug: t.slug, - kind: t.kind, - sortOrder: t.sortOrder, - routingModeDefault: t.routingModeDefault, - decisionMode: t.decisionMode, - stages: t.stages.map((s) => ({ - id: s.id, - name: s.name, - slug: s.slug, - stageType: s.stageType, - sortOrder: s.sortOrder, - configJson: s.configJson, - })), - awardConfig: t.awardConfig, - })), - autoTransitions: true, - }) - } - - const isSaving = updateStructureMutation.isPending - - const sections = [ - { title: 'Basics', description: 'Pipeline name, slug, and program', isValid: basicsValid }, - { title: 'Intake', description: 'Submission windows and file requirements', isValid: !!intakeStage }, - { title: 'Main Track Stages', description: `${mainTrack?.stages.length ?? 0} stages configured`, isValid: tracksValid }, - { title: 'Filtering', description: 'Gate rules and AI screening settings', isValid: !!filterStage }, - { title: 'Assignment', description: 'Jury evaluation assignment strategy', isValid: !!evalStage }, - { title: 'Awards', description: `${state.tracks.filter((t) => t.kind === 'AWARD').length} award tracks`, isValid: true }, - { title: 'Live Finals', description: 'Voting, cohorts, and reveal settings', isValid: !!liveStage }, - { title: 'Notifications', description: 'Event notifications and override governance', isValid: true }, - { title: 'Review', description: 'Validation summary', isValid: allValid }, - ] - - return ( -
- {/* Header */} -
-
- - - -
-

Edit Pipeline

-

- {pipeline?.name} - {isActive && ' (Active — some fields are locked)'} -

-
-
- -
- - {/* Wizard Sections */} -
- setOpenSection(openSection === 0 ? -1 : 0)} - isValid={sections[0].isValid} - > - - - - setOpenSection(openSection === 1 ? -1 : 1)} - isValid={sections[1].isValid} - > - - updateStageConfig('INTAKE', c as unknown as Record) - } - isActive={isActive} - /> - - - setOpenSection(openSection === 2 ? -1 : 2)} - isValid={sections[2].isValid} - > - - - - setOpenSection(openSection === 3 ? -1 : 3)} - isValid={sections[3].isValid} - > - - updateStageConfig('FILTER', c as unknown as Record) - } - isActive={isActive} - /> - - - setOpenSection(openSection === 4 ? -1 : 4)} - isValid={sections[4].isValid} - > - - updateStageConfig('EVALUATION', c as unknown as Record) - } - isActive={isActive} - /> - - - setOpenSection(openSection === 5 ? -1 : 5)} - isValid={sections[5].isValid} - > - updateState({ tracks })} - isActive={isActive} - /> - - - setOpenSection(openSection === 6 ? -1 : 6)} - isValid={sections[6].isValid} - > - - updateStageConfig('LIVE_FINAL', c as unknown as Record) - } - isActive={isActive} - /> - - - setOpenSection(openSection === 7 ? -1 : 7)} - isValid={sections[7].isValid} - > - updateState({ notificationConfig })} - overridePolicy={state.overridePolicy} - onOverridePolicyChange={(overridePolicy) => - updateState({ overridePolicy }) - } - isActive={isActive} - /> - - - setOpenSection(openSection === 8 ? -1 : 8)} - isValid={sections[8].isValid} - > - - -
-
- ) +export default async function EditPipelinePage({ params }: EditPipelinePageProps) { + const { id } = await params + // Editing now happens inline on the detail page + redirect(`/admin/rounds/pipeline/${id}` as never) } diff --git a/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx b/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx index 242ed65..76c860b 100644 --- a/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx +++ b/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useParams } from 'next/navigation' import Link from 'next/link' import type { Route } from 'next' @@ -10,12 +10,8 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, - CardDescription, - CardHeader, - CardTitle, } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { DropdownMenu, DropdownMenuContent, @@ -27,7 +23,6 @@ import { toast } from 'sonner' import { cn } from '@/lib/utils' import { ArrowLeft, - Edit, MoreHorizontal, Rocket, Archive, @@ -35,8 +30,14 @@ import { Layers, GitBranch, Loader2, + ChevronDown, } from 'lucide-react' +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 { usePipelineInlineEdit } from '@/hooks/use-pipeline-inline-edit' + 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' @@ -51,15 +52,6 @@ const statusColors: Record = { CLOSED: 'bg-blue-100 text-blue-700', } -const stageTypeColors: Record = { - INTAKE: 'bg-blue-100 text-blue-700', - FILTER: 'bg-amber-100 text-amber-700', - EVALUATION: 'bg-purple-100 text-purple-700', - SELECTION: 'bg-rose-100 text-rose-700', - LIVE_FINAL: 'bg-emerald-100 text-emerald-700', - RESULTS: 'bg-cyan-100 text-cyan-700', -} - function StagePanel({ stageId, stageType, @@ -100,20 +92,14 @@ export default function PipelineDetailPage() { const [selectedTrackId, setSelectedTrackId] = useState(null) const [selectedStageId, setSelectedStageId] = useState(null) + const stagePanelRef = useRef(null) + const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({ id: pipelineId, }) - // Auto-select first track and stage - useEffect(() => { - if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) { - const firstTrack = pipeline.tracks[0] - setSelectedTrackId(firstTrack.id) - if (firstTrack.stages.length > 0) { - setSelectedStageId(firstTrack.stages[0].id) - } - } - }, [pipeline, selectedTrackId]) + const { isUpdating, updatePipeline, updateStageConfig } = + usePipelineInlineEdit(pipelineId) const publishMutation = trpc.pipeline.publish.useMutation({ onSuccess: () => toast.success('Pipeline published'), @@ -125,6 +111,25 @@ export default function PipelineDetailPage() { onError: (err) => toast.error(err.message), }) + // Auto-select first track and stage 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) + } + } + }, [pipeline, selectedTrackId]) + + // Scroll to stage panel when a stage is selected + useEffect(() => { + if (selectedStageId && stagePanelRef.current) { + stagePanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + }, [selectedStageId]) + if (isLoading) { return (
@@ -170,12 +175,27 @@ export default function PipelineDetailPage() { setSelectedTrackId(trackId) const track = pipeline.tracks.find((t) => t.id === trackId) if (track && track.stages.length > 0) { - setSelectedStageId(track.stages[0].id) + const firstStage = track.stages.sort((a, b) => a.sortOrder - b.sortOrder)[0] + setSelectedStageId(firstStage.id) } else { setSelectedStageId(null) } } + const handleStageSelect = (stageId: string) => { + setSelectedStageId(stageId) + } + + const handleStatusChange = async (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => { + await updateMutation.mutateAsync({ + id: pipelineId, + status: newStatus, + }) + } + + // Prepare flowchart data for the selected track + const flowchartTracks = selectedTrack ? [selectedTrack] : [] + return (
{/* Header */} @@ -188,30 +208,69 @@ export default function PipelineDetailPage() {
-

{pipeline.name}

- - {pipeline.status} - + 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.slug} -

- - -
- {/* Track Tabs */} - {pipeline.tracks.length > 0 && ( - - - {pipeline.tracks - .sort((a, b) => a.sortOrder - b.sortOrder) - .map((track) => ( - - {track.name} - - {track.kind} - - - ))} - - - {pipeline.tracks.map((track) => ( - - {/* Track Info */} - - -
-
- {track.name} - - {track.slug} - -
-
- {track.routingMode && ( - - {track.routingMode} - - )} - {track.decisionMode && ( - - {track.decisionMode} - - )} -
-
-
-
- - {/* Stage Tabs within Track */} - {track.stages.length > 0 ? ( - 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} + /> + + {/* Stage Activity Panel */} + | null} + /> +
+ ) : ( + + +

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

+
+
+ )} +
) } diff --git a/src/app/(admin)/admin/rounds/pipelines/page.tsx b/src/app/(admin)/admin/rounds/pipelines/page.tsx index 241d1be..ad50cb7 100644 --- a/src/app/(admin)/admin/rounds/pipelines/page.tsx +++ b/src/app/(admin)/admin/rounds/pipelines/page.tsx @@ -1,7 +1,5 @@ 'use client' -import { useEffect } from 'react' -import { useRouter } from 'next/navigation' import Link from 'next/link' import type { Route } from 'next' import { trpc } from '@/lib/trpc/client' @@ -10,39 +8,45 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, - CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' import { Plus, - MoreHorizontal, - Eye, - Edit, Layers, GitBranch, Calendar, + Workflow, } from 'lucide-react' import { cn } from '@/lib/utils' -import { format } from 'date-fns' +import { formatDistanceToNow } from 'date-fns' import { useEdition } from '@/contexts/edition-context' -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', -} +const statusConfig = { + DRAFT: { + label: 'Draft', + bgClass: 'bg-gray-100 text-gray-700', + dotClass: 'bg-gray-500', + }, + ACTIVE: { + label: 'Active', + bgClass: 'bg-emerald-100 text-emerald-700', + dotClass: 'bg-emerald-500', + }, + CLOSED: { + label: 'Closed', + bgClass: 'bg-blue-100 text-blue-700', + dotClass: 'bg-blue-500', + }, + ARCHIVED: { + label: 'Archived', + bgClass: 'bg-muted text-muted-foreground', + dotClass: 'bg-muted-foreground', + }, +} as const export default function PipelineListPage() { - const router = useRouter() const { currentEdition } = useEdition() const programId = currentEdition?.id @@ -51,13 +55,6 @@ export default function PipelineListPage() { { enabled: !!programId } ) - // Auto-redirect when there's exactly one pipeline - useEffect(() => { - if (!isLoading && pipelines && pipelines.length === 1) { - router.replace(`/admin/rounds/pipeline/${pipelines[0].id}` as Route) - } - }, [isLoading, pipelines, router]) - if (!programId) { return (
@@ -122,17 +119,20 @@ export default function PipelineListPage() { {/* Empty State */} {!isLoading && (!pipelines || pipelines.length === 0) && ( - - - -

No Pipelines Yet

-

- Create your first pipeline to start managing project evaluation + + +

+ +
+

No Pipelines Yet

+

+ Pipelines organize your project evaluation workflow into tracks and stages. + Create your first pipeline to get started with managing project evaluations.

-
@@ -142,80 +142,101 @@ export default function PipelineListPage() { {/* Pipeline Cards */} {pipelines && pipelines.length > 0 && (
- {pipelines.map((pipeline) => ( - - - -
-
- - {pipeline.name} - - - {pipeline.slug} - -
-
+ {pipelines.map((pipeline) => { + const status = pipeline.status as keyof typeof statusConfig + const config = statusConfig[status] || statusConfig.DRAFT + const description = (pipeline.settingsJson as Record | null)?.description as string | undefined + + return ( + + + +
+
+ + {pipeline.name} + +

+ {pipeline.slug} +

+
- {pipeline.status} + + {config.label} - - - - - - - - - View - - - - - - Edit - - - -
-
- - -
-
- - {pipeline._count.tracks} tracks + + {/* Description */} + {description && ( +

+ {description} +

+ )} + + + + {/* Track Indicator - Simplified visualization */} +
+
+ + + {pipeline._count.tracks === 0 + ? 'No tracks' + : pipeline._count.tracks === 1 + ? '1 track' + : `${pipeline._count.tracks} tracks`} + +
+ {pipeline._count.tracks > 0 && ( +
+ {Array.from({ length: Math.min(pipeline._count.tracks, 5) }).map((_, i) => ( +
+
+
+ ))} + {pipeline._count.tracks > 5 && ( + + +{pipeline._count.tracks - 5} + + )} +
+ )}
-
- - {pipeline._count.routingRules} rules + + {/* Stats */} +
+
+
+ + Routing rules +
+ + {pipeline._count.routingRules} + +
+ +
+ Updated {formatDistanceToNow(new Date(pipeline.updatedAt))} ago +
-
-

- Created {format(new Date(pipeline.createdAt), 'MMM d, yyyy')} -

- - - - ))} + + + + ) + })}
)}
diff --git a/src/components/admin/pipeline/pipeline-flowchart.tsx b/src/components/admin/pipeline/pipeline-flowchart.tsx new file mode 100644 index 0000000..4823b0a --- /dev/null +++ b/src/components/admin/pipeline/pipeline-flowchart.tsx @@ -0,0 +1,270 @@ +'use client' + +import { useRef, useEffect, useState, useCallback } from 'react' +import { motion } from 'motion/react' +import { cn } from '@/lib/utils' +import { Badge } from '@/components/ui/badge' + +type StageNode = { + id: string + name: string + stageType: string + sortOrder: number + _count?: { projectStageStates: number } +} + +type FlowchartTrack = { + id: string + name: string + kind: string + sortOrder: number + stages: StageNode[] +} + +type PipelineFlowchartProps = { + tracks: FlowchartTrack[] + selectedStageId?: string | null + onStageSelect?: (stageId: string) => void + className?: string + compact?: boolean +} + +const stageTypeColors: Record = { + INTAKE: { bg: '#eff6ff', border: '#93c5fd', text: '#1d4ed8', glow: '#3b82f6' }, + FILTER: { bg: '#fffbeb', border: '#fcd34d', text: '#b45309', glow: '#f59e0b' }, + EVALUATION: { bg: '#faf5ff', border: '#c084fc', text: '#7e22ce', glow: '#a855f7' }, + SELECTION: { bg: '#fff1f2', border: '#fda4af', text: '#be123c', glow: '#f43f5e' }, + LIVE_FINAL: { bg: '#ecfdf5', border: '#6ee7b7', text: '#047857', glow: '#10b981' }, + RESULTS: { bg: '#ecfeff', border: '#67e8f9', text: '#0e7490', glow: '#06b6d4' }, +} + +const NODE_WIDTH = 140 +const NODE_HEIGHT = 70 +const NODE_GAP = 32 +const ARROW_SIZE = 6 +const TRACK_LABEL_HEIGHT = 28 +const TRACK_GAP = 20 + +export function PipelineFlowchart({ + tracks, + selectedStageId, + onStageSelect, + className, + compact = false, +}: PipelineFlowchartProps) { + const containerRef = useRef(null) + const [hoveredStageId, setHoveredStageId] = useState(null) + + const sortedTracks = [...tracks].sort((a, b) => a.sortOrder - b.sortOrder) + + // Calculate dimensions + const nodeW = compact ? 100 : NODE_WIDTH + const nodeH = compact ? 50 : NODE_HEIGHT + const gap = compact ? 20 : NODE_GAP + + const maxStages = Math.max(...sortedTracks.map((t) => t.stages.length), 1) + const totalWidth = maxStages * nodeW + (maxStages - 1) * gap + 40 + const totalHeight = + sortedTracks.length * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) - TRACK_GAP + 20 + + const getNodePosition = useCallback( + (trackIndex: number, stageIndex: number) => { + const x = 20 + stageIndex * (nodeW + gap) + const y = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) + TRACK_LABEL_HEIGHT + return { x, y } + }, + [nodeW, nodeH, gap] + ) + + return ( +
+ + + + + + {/* Glow filter for selected node */} + + + + + + + + + + + + {sortedTracks.map((track, trackIndex) => { + const sortedStages = [...track.stages].sort( + (a, b) => a.sortOrder - b.sortOrder + ) + const trackY = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) + + return ( + + {/* Track label */} + + {track.name} + {track.kind !== 'MAIN' && ` (${track.kind})`} + + + {/* Arrows between stages */} + {sortedStages.map((stage, stageIndex) => { + if (stageIndex === 0) return null + const from = getNodePosition(trackIndex, stageIndex - 1) + const to = getNodePosition(trackIndex, stageIndex) + const arrowY = from.y + nodeH / 2 + return ( + + ) + })} + + {/* Stage nodes */} + {sortedStages.map((stage, stageIndex) => { + const pos = getNodePosition(trackIndex, stageIndex) + const isSelected = selectedStageId === stage.id + const isHovered = hoveredStageId === stage.id + const colors = stageTypeColors[stage.stageType] ?? { + bg: '#f8fafc', + border: '#cbd5e1', + text: '#475569', + glow: '#64748b', + } + const projectCount = stage._count?.projectStageStates ?? 0 + + return ( + onStageSelect?.(stage.id)} + onMouseEnter={() => setHoveredStageId(stage.id)} + onMouseLeave={() => setHoveredStageId(null)} + className={cn(onStageSelect && 'cursor-pointer')} + filter={isSelected ? 'url(#selectedGlow)' : undefined} + > + {/* Selection ring */} + {isSelected && ( + + )} + + {/* Node background */} + + + {/* Stage name */} + + {stage.name.length > (compact ? 12 : 16) + ? stage.name.slice(0, compact ? 10 : 14) + '...' + : stage.name} + + + {/* Type badge */} + + {stage.stageType.replace('_', ' ')} + + + {/* Project count */} + {!compact && projectCount > 0 && ( + <> + + + {projectCount} + + + )} + + ) + })} + + ) + })} + +
+ ) +} diff --git a/src/components/admin/pipeline/stage-config-editor.tsx b/src/components/admin/pipeline/stage-config-editor.tsx new file mode 100644 index 0000000..97c3fc1 --- /dev/null +++ b/src/components/admin/pipeline/stage-config-editor.tsx @@ -0,0 +1,334 @@ +'use client' + +import { useState, useCallback } from 'react' +import { EditableCard } from '@/components/ui/editable-card' +import { Badge } from '@/components/ui/badge' +import { cn } from '@/lib/utils' +import { + Inbox, + Filter, + ClipboardCheck, + Trophy, + Tv, + BarChart3, +} from 'lucide-react' + +import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section' +import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section' +import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section' +import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section' + +import { + defaultIntakeConfig, + defaultFilterConfig, + defaultEvaluationConfig, + defaultLiveConfig, +} from '@/lib/pipeline-defaults' + +import type { + IntakeConfig, + FilterConfig, + EvaluationConfig, + LiveFinalConfig, +} from '@/types/pipeline-wizard' + +type StageConfigEditorProps = { + stageId: string + stageName: string + stageType: string + configJson: Record | null + onSave: (stageId: string, configJson: Record) => Promise + isSaving?: boolean +} + +const stageIcons: Record = { + INTAKE: , + FILTER: , + EVALUATION: , + SELECTION: , + LIVE_FINAL: , + RESULTS: , +} + +function ConfigSummary({ + stageType, + configJson, +}: { + stageType: string + configJson: Record | null +}) { + if (!configJson) { + return ( +

+ No configuration set +

+ ) + } + + switch (stageType) { + case 'INTAKE': { + const config = configJson as unknown as IntakeConfig + return ( +
+
+ Submission Window: + + {config.submissionWindowEnabled ? 'Enabled' : 'Disabled'} + +
+
+ Late Policy: + {config.lateSubmissionPolicy} + {config.lateGraceHours > 0 && ( + + ({config.lateGraceHours}h grace) + + )} +
+
+ File Requirements: + {config.fileRequirements?.length ?? 0} configured +
+
+ ) + } + + case 'FILTER': { + const config = configJson as unknown as FilterConfig + return ( +
+
+ Rules: + {config.rules?.length ?? 0} eligibility rules +
+
+ AI Screening: + + {config.aiRubricEnabled ? 'Enabled' : 'Disabled'} + +
+ {config.aiRubricEnabled && ( +
+ Confidence: + + High {config.aiConfidenceThresholds?.high ?? 0.85} / Med{' '} + {config.aiConfidenceThresholds?.medium ?? 0.6} + +
+ )} +
+ Manual Queue: + + {config.manualQueueEnabled ? 'Enabled' : 'Disabled'} + +
+
+ ) + } + + case 'EVALUATION': { + const config = configJson as unknown as EvaluationConfig + return ( +
+
+ Required Reviews: + {config.requiredReviews ?? 3} +
+
+ Load per Juror: + + {config.minLoadPerJuror ?? 5} - {config.maxLoadPerJuror ?? 20} + +
+
+ Overflow Policy: + + {(config.overflowPolicy ?? 'queue').replace('_', ' ')} + +
+
+ ) + } + + case 'SELECTION': { + return ( +
+
+ Ranking Method: + + {((configJson.rankingMethod as string) ?? 'score_average').replace( + /_/g, + ' ' + )} + +
+
+ Tie Breaker: + + {((configJson.tieBreaker as string) ?? 'admin_decides').replace( + /_/g, + ' ' + )} + +
+ {configJson.finalistCount != null && ( +
+ Finalist Count: + {String(configJson.finalistCount)} +
+ )} +
+ ) + } + + case 'LIVE_FINAL': { + const config = configJson as unknown as LiveFinalConfig + return ( +
+
+ Jury Voting: + + {config.juryVotingEnabled ? 'Enabled' : 'Disabled'} + +
+
+ Audience Voting: + + {config.audienceVotingEnabled ? 'Enabled' : 'Disabled'} + + {config.audienceVotingEnabled && ( + + ({config.audienceVoteWeight}% weight) + + )} +
+
+ Reveal: + {config.revealPolicy ?? 'ceremony'} +
+
+ ) + } + + case 'RESULTS': { + return ( +
+
+ Publication: + + {((configJson.publicationMode as string) ?? 'manual').replace( + /_/g, + ' ' + )} + +
+
+ Show Scores: + + {configJson.showDetailedScores ? 'Yes' : 'No'} + +
+
+ ) + } + + default: + return ( +

+ Configuration view not available for this stage type +

+ ) + } +} + +export function StageConfigEditor({ + stageId, + stageName, + stageType, + configJson, + onSave, + isSaving = false, +}: StageConfigEditorProps) { + const [localConfig, setLocalConfig] = useState>( + () => configJson ?? {} + ) + + const handleSave = useCallback(async () => { + await onSave(stageId, localConfig) + }, [stageId, localConfig, onSave]) + + const renderEditor = () => { + switch (stageType) { + case 'INTAKE': { + const config = { + ...defaultIntakeConfig(), + ...(localConfig as object), + } as IntakeConfig + return ( + setLocalConfig(c as unknown as Record)} + /> + ) + } + case 'FILTER': { + const config = { + ...defaultFilterConfig(), + ...(localConfig as object), + } as FilterConfig + return ( + setLocalConfig(c as unknown as Record)} + /> + ) + } + case 'EVALUATION': { + const config = { + ...defaultEvaluationConfig(), + ...(localConfig as object), + } as EvaluationConfig + return ( + setLocalConfig(c as unknown as Record)} + /> + ) + } + case 'LIVE_FINAL': { + const config = { + ...defaultLiveConfig(), + ...(localConfig as object), + } as LiveFinalConfig + return ( + setLocalConfig(c as unknown as Record)} + /> + ) + } + case 'SELECTION': + case 'RESULTS': + return ( +
+ Configuration for {stageType.replace('_', ' ')} stages is managed + through the stage settings. +
+ ) + default: + return null + } + } + + return ( + } + onSave={handleSave} + isSaving={isSaving} + > + {renderEditor()} + + ) +} diff --git a/src/components/admin/pipeline/wizard-section.tsx b/src/components/admin/pipeline/wizard-section.tsx deleted file mode 100644 index b07dc22..0000000 --- a/src/components/admin/pipeline/wizard-section.tsx +++ /dev/null @@ -1,92 +0,0 @@ -'use client' - -import { cn } from '@/lib/utils' -import { Card, CardContent, CardHeader } from '@/components/ui/card' -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible' -import { Badge } from '@/components/ui/badge' -import { ChevronDown, CheckCircle2, AlertCircle, Info } from 'lucide-react' - -type WizardSectionProps = { - title: string - description?: string - helpText?: string - stepNumber: number - isOpen: boolean - onToggle: () => void - isValid: boolean - hasErrors?: boolean - children: React.ReactNode -} - -export function WizardSection({ - title, - description, - helpText, - stepNumber, - isOpen, - onToggle, - isValid, - hasErrors, - children, -}: WizardSectionProps) { - return ( - - - - -
- - {isValid ? ( - - ) : hasErrors ? ( - - ) : ( - stepNumber - )} - -
-

{title}

- {description && !isOpen && ( -

- {description} -

- )} -
- -
-
-
- - - {helpText && ( -
- - {helpText} -
- )} - {children} -
-
-
-
- ) -} diff --git a/src/components/ui/editable-card.tsx b/src/components/ui/editable-card.tsx new file mode 100644 index 0000000..1f9fbe1 --- /dev/null +++ b/src/components/ui/editable-card.tsx @@ -0,0 +1,121 @@ +'use client' + +import { useState } from 'react' +import { motion, AnimatePresence } from 'motion/react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { Pencil, X, Loader2 } from 'lucide-react' + +type EditableCardProps = { + title: string + icon?: React.ReactNode + summary: React.ReactNode + children: React.ReactNode + onSave?: () => void | Promise + isSaving?: boolean + alwaysShowEdit?: boolean + defaultEditing?: boolean + className?: string +} + +export function EditableCard({ + title, + icon, + summary, + children, + onSave, + isSaving = false, + alwaysShowEdit = false, + defaultEditing = false, + className, +}: EditableCardProps) { + const [isEditing, setIsEditing] = useState(defaultEditing) + + const handleSave = async () => { + if (onSave) { + await onSave() + } + setIsEditing(false) + } + + return ( + + +
+
+ {icon && ( + {icon} + )} + {title} +
+ {!isEditing && ( + + )} + {isEditing && ( + + )} +
+
+ + + {isEditing ? ( + +
+ {children} + {onSave && ( +
+ +
+ )} +
+
+ ) : ( + + {summary} + + )} +
+
+
+ ) +} diff --git a/src/components/ui/inline-editable-text.tsx b/src/components/ui/inline-editable-text.tsx new file mode 100644 index 0000000..addb561 --- /dev/null +++ b/src/components/ui/inline-editable-text.tsx @@ -0,0 +1,179 @@ +'use client' + +import { useState, useRef, useEffect, useCallback } from 'react' +import { motion, AnimatePresence } from 'motion/react' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { cn } from '@/lib/utils' +import { Pencil, Check, X } from 'lucide-react' + +type InlineEditableTextVariant = 'h1' | 'h2' | 'body' | 'mono' + +type InlineEditableTextProps = { + value: string + onSave: (newValue: string) => void | Promise + variant?: InlineEditableTextVariant + placeholder?: string + multiline?: boolean + disabled?: boolean + className?: string +} + +const variantStyles: Record = { + h1: 'text-xl font-bold', + h2: 'text-base font-semibold', + body: 'text-sm', + mono: 'text-sm font-mono text-muted-foreground', +} + +export function InlineEditableText({ + value, + onSave, + variant = 'body', + placeholder = 'Click to edit...', + multiline = false, + disabled = false, + className, +}: InlineEditableTextProps) { + const [isEditing, setIsEditing] = useState(false) + const [editValue, setEditValue] = useState(value) + const [isSaving, setIsSaving] = useState(false) + const inputRef = useRef(null) + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [isEditing]) + + useEffect(() => { + setEditValue(value) + }, [value]) + + const handleSave = useCallback(async () => { + const trimmed = editValue.trim() + if (trimmed === value) { + setIsEditing(false) + return + } + if (!trimmed) { + setEditValue(value) + setIsEditing(false) + return + } + setIsSaving(true) + try { + await onSave(trimmed) + setIsEditing(false) + } catch { + setEditValue(value) + } finally { + setIsSaving(false) + } + }, [editValue, value, onSave]) + + const handleCancel = useCallback(() => { + setEditValue(value) + setIsEditing(false) + }, [value]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + handleCancel() + } + if (e.key === 'Enter') { + if (multiline && !e.ctrlKey) return + e.preventDefault() + handleSave() + } + }, + [handleCancel, handleSave, multiline] + ) + + if (disabled) { + return ( + + {value || {placeholder}} + + ) + } + + return ( + + {isEditing ? ( + + {multiline ? ( +