Pipeline UI/UX redesign: inline editing, flowchart, sidebar stepper
Build and Push Docker Image / build (push) Failing after 8m59s
Details
Build and Push Docker Image / build (push) Failing after 8m59s
Details
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
70cfad7d46
commit
59f90ccc37
|
|
@ -6,10 +6,11 @@ import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
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 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 { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
|
||||||
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
||||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-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 programId = searchParams.get('programId') || currentEdition?.id || ''
|
||||||
|
|
||||||
const [state, setState] = useState<WizardState>(() => defaultWizardState(programId))
|
const [state, setState] = useState<WizardState>(() => defaultWizardState(programId))
|
||||||
const [openSection, setOpenSection] = useState(0)
|
const [currentStep, setCurrentStep] = useState(0)
|
||||||
const initialStateRef = useRef(JSON.stringify(state))
|
const initialStateRef = useRef(JSON.stringify(state))
|
||||||
|
|
||||||
// Update programId in state when edition context loads
|
// Update programId in state when edition context loads
|
||||||
|
|
@ -129,9 +130,9 @@ export default function NewPipelinePage() {
|
||||||
const validation = validateAll(state)
|
const validation = validateAll(state)
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
toast.error('Please fix validation errors before saving')
|
toast.error('Please fix validation errors before saving')
|
||||||
// Open first section with errors
|
// Navigate to first section with errors
|
||||||
if (!validation.sections.basics.valid) setOpenSection(0)
|
if (!validation.sections.basics.valid) setCurrentStep(0)
|
||||||
else if (!validation.sections.tracks.valid) setOpenSection(2)
|
else if (!validation.sections.tracks.valid) setCurrentStep(2)
|
||||||
return
|
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) {
|
if (!programId) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -190,230 +192,161 @@ export default function NewPipelinePage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections = [
|
// Step configuration
|
||||||
|
const steps: StepConfig[] = [
|
||||||
{
|
{
|
||||||
title: 'Basics',
|
title: 'Basics',
|
||||||
description: 'Pipeline name, slug, and program',
|
description: 'Pipeline name and program',
|
||||||
isValid: basicsValid,
|
isValid: basicsValid,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Intake',
|
title: 'Intake',
|
||||||
description: 'Submission windows and file requirements',
|
description: 'Submission window & files',
|
||||||
isValid: !!intakeStage,
|
isValid: !!intakeStage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Main Track Stages',
|
title: 'Main Track Stages',
|
||||||
description: `${mainTrack?.stages.length ?? 0} stages configured`,
|
description: 'Configure pipeline stages',
|
||||||
isValid: tracksValid,
|
isValid: tracksValid,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Filtering',
|
title: 'Screening',
|
||||||
description: 'Gate rules and AI screening settings',
|
description: 'Gate rules and AI screening',
|
||||||
isValid: !!filterStage,
|
isValid: !!filterStage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Assignment',
|
title: 'Evaluation',
|
||||||
description: 'Jury evaluation assignment strategy',
|
description: 'Jury assignment strategy',
|
||||||
isValid: !!evalStage,
|
isValid: !!evalStage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Awards',
|
title: 'Awards',
|
||||||
description: `${state.tracks.filter((t) => t.kind === 'AWARD').length} award tracks`,
|
description: 'Special award tracks',
|
||||||
isValid: true, // Awards are optional
|
isValid: true, // Awards are optional
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Live Finals',
|
title: 'Live Finals',
|
||||||
description: 'Voting, cohorts, and reveal settings',
|
description: 'Voting and reveal settings',
|
||||||
isValid: !!liveStage,
|
isValid: !!liveStage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Notifications',
|
title: 'Notifications',
|
||||||
description: 'Event notifications and override governance',
|
description: 'Event notifications',
|
||||||
isValid: true, // Always valid
|
isValid: true, // Always valid
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Review & Publish',
|
title: 'Review & Create',
|
||||||
description: 'Validation summary and publish controls',
|
description: 'Validation summary',
|
||||||
isValid: allValid,
|
isValid: allValid,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 pb-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<Link href="/admin/rounds/pipelines">
|
||||||
<Link href="/admin/rounds/pipelines">
|
<Button variant="ghost" size="icon">
|
||||||
<Button variant="ghost" size="icon">
|
<ArrowLeft className="h-4 w-4" />
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Create Pipeline</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Configure the full pipeline structure for project evaluation
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
disabled={isSaving || !allValid}
|
|
||||||
onClick={() => handleSave(false)}
|
|
||||||
>
|
|
||||||
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
|
||||||
Save Draft
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={isSaving || !allValid}
|
|
||||||
onClick={() => handleSave(true)}
|
|
||||||
>
|
|
||||||
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Rocket className="h-4 w-4 mr-2" />}
|
|
||||||
Save & Publish
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Create Pipeline</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Configure the full pipeline structure for project evaluation
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Wizard Sections */}
|
{/* Sidebar Stepper */}
|
||||||
<div className="space-y-3">
|
<SidebarStepper
|
||||||
{/* 0: Basics */}
|
steps={steps}
|
||||||
<WizardSection
|
currentStep={currentStep}
|
||||||
stepNumber={1}
|
onStepChange={setCurrentStep}
|
||||||
title={sections[0].title}
|
onSave={() => handleSave(false)}
|
||||||
description={sections[0].description}
|
onSubmit={() => handleSave(true)}
|
||||||
isOpen={openSection === 0}
|
isSaving={isSaving}
|
||||||
onToggle={() => setOpenSection(openSection === 0 ? -1 : 0)}
|
isSubmitting={isSubmitting}
|
||||||
isValid={sections[0].isValid}
|
saveLabel="Save Draft"
|
||||||
>
|
submitLabel="Save & Publish"
|
||||||
|
canSubmit={allValid}
|
||||||
|
>
|
||||||
|
{/* Step 0: Basics */}
|
||||||
|
<div>
|
||||||
<BasicsSection state={state} onChange={updateState} />
|
<BasicsSection state={state} onChange={updateState} />
|
||||||
</WizardSection>
|
</div>
|
||||||
|
|
||||||
{/* 1: Intake */}
|
{/* Step 1: Intake */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={2}
|
|
||||||
title={sections[1].title}
|
|
||||||
description={sections[1].description}
|
|
||||||
isOpen={openSection === 1}
|
|
||||||
onToggle={() => setOpenSection(openSection === 1 ? -1 : 1)}
|
|
||||||
isValid={sections[1].isValid}
|
|
||||||
>
|
|
||||||
<IntakeSection
|
<IntakeSection
|
||||||
config={intakeConfig}
|
config={intakeConfig}
|
||||||
onChange={(c) =>
|
onChange={(c) =>
|
||||||
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</WizardSection>
|
</div>
|
||||||
|
|
||||||
{/* 2: Main Track Stages */}
|
{/* Step 2: Main Track Stages */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={3}
|
|
||||||
title={sections[2].title}
|
|
||||||
description={sections[2].description}
|
|
||||||
isOpen={openSection === 2}
|
|
||||||
onToggle={() => setOpenSection(openSection === 2 ? -1 : 2)}
|
|
||||||
isValid={sections[2].isValid}
|
|
||||||
>
|
|
||||||
<MainTrackSection
|
<MainTrackSection
|
||||||
stages={mainTrack?.stages ?? []}
|
stages={mainTrack?.stages ?? []}
|
||||||
onChange={updateMainTrackStages}
|
onChange={updateMainTrackStages}
|
||||||
/>
|
/>
|
||||||
</WizardSection>
|
</div>
|
||||||
|
|
||||||
{/* 3: Filtering */}
|
{/* Step 3: Screening */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={4}
|
|
||||||
title={sections[3].title}
|
|
||||||
description={sections[3].description}
|
|
||||||
isOpen={openSection === 3}
|
|
||||||
onToggle={() => setOpenSection(openSection === 3 ? -1 : 3)}
|
|
||||||
isValid={sections[3].isValid}
|
|
||||||
>
|
|
||||||
<FilteringSection
|
<FilteringSection
|
||||||
config={filterConfig}
|
config={filterConfig}
|
||||||
onChange={(c) =>
|
onChange={(c) =>
|
||||||
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</WizardSection>
|
</div>
|
||||||
|
|
||||||
{/* 4: Assignment */}
|
{/* Step 4: Evaluation */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={5}
|
|
||||||
title={sections[4].title}
|
|
||||||
description={sections[4].description}
|
|
||||||
isOpen={openSection === 4}
|
|
||||||
onToggle={() => setOpenSection(openSection === 4 ? -1 : 4)}
|
|
||||||
isValid={sections[4].isValid}
|
|
||||||
>
|
|
||||||
<AssignmentSection
|
<AssignmentSection
|
||||||
config={evalConfig}
|
config={evalConfig}
|
||||||
onChange={(c) =>
|
onChange={(c) =>
|
||||||
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</WizardSection>
|
</div>
|
||||||
|
|
||||||
{/* 5: Awards */}
|
{/* Step 5: Awards */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={6}
|
<AwardsSection
|
||||||
title={sections[5].title}
|
tracks={state.tracks}
|
||||||
description={sections[5].description}
|
onChange={(tracks) => updateState({ tracks })}
|
||||||
isOpen={openSection === 5}
|
/>
|
||||||
onToggle={() => setOpenSection(openSection === 5 ? -1 : 5)}
|
</div>
|
||||||
isValid={sections[5].isValid}
|
|
||||||
>
|
|
||||||
<AwardsSection tracks={state.tracks} onChange={(tracks) => updateState({ tracks })} />
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
{/* 6: Live Finals */}
|
{/* Step 6: Live Finals */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={7}
|
|
||||||
title={sections[6].title}
|
|
||||||
description={sections[6].description}
|
|
||||||
isOpen={openSection === 6}
|
|
||||||
onToggle={() => setOpenSection(openSection === 6 ? -1 : 6)}
|
|
||||||
isValid={sections[6].isValid}
|
|
||||||
>
|
|
||||||
<LiveFinalsSection
|
<LiveFinalsSection
|
||||||
config={liveConfig}
|
config={liveConfig}
|
||||||
onChange={(c) =>
|
onChange={(c) =>
|
||||||
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</WizardSection>
|
</div>
|
||||||
|
|
||||||
{/* 7: Notifications */}
|
{/* Step 7: Notifications */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={8}
|
|
||||||
title={sections[7].title}
|
|
||||||
description={sections[7].description}
|
|
||||||
isOpen={openSection === 7}
|
|
||||||
onToggle={() => setOpenSection(openSection === 7 ? -1 : 7)}
|
|
||||||
isValid={sections[7].isValid}
|
|
||||||
>
|
|
||||||
<NotificationsSection
|
<NotificationsSection
|
||||||
config={state.notificationConfig}
|
config={state.notificationConfig}
|
||||||
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
||||||
overridePolicy={state.overridePolicy}
|
overridePolicy={state.overridePolicy}
|
||||||
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
|
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
|
||||||
/>
|
/>
|
||||||
</WizardSection>
|
</div>
|
||||||
|
|
||||||
{/* 8: Review */}
|
{/* Step 8: Review & Create */}
|
||||||
<WizardSection
|
<div>
|
||||||
stepNumber={9}
|
|
||||||
title={sections[8].title}
|
|
||||||
description={sections[8].description}
|
|
||||||
isOpen={openSection === 8}
|
|
||||||
onToggle={() => setOpenSection(openSection === 8 ? -1 : 8)}
|
|
||||||
isValid={sections[8].isValid}
|
|
||||||
>
|
|
||||||
<ReviewSection state={state} />
|
<ReviewSection state={state} />
|
||||||
</WizardSection>
|
</div>
|
||||||
</div>
|
</SidebarStepper>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,429 +1,11 @@
|
||||||
'use client'
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
type EditPipelinePageProps = {
|
||||||
import { useRouter, useParams } from 'next/navigation'
|
params: Promise<{ id: string }>
|
||||||
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<string, unknown>) ?? {}
|
|
||||||
|
|
||||||
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<string, unknown>) ?? {},
|
|
||||||
})),
|
|
||||||
awardConfig: t.specialAward
|
|
||||||
? {
|
|
||||||
name: t.specialAward.name,
|
|
||||||
description: t.specialAward.description ?? undefined,
|
|
||||||
scoringMode: t.specialAward.scoringMode as NonNullable<WizardTrackConfig['awardConfig']>['scoringMode'],
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
notificationConfig:
|
|
||||||
(settings.notificationConfig as Record<string, boolean>) ??
|
|
||||||
defaultNotificationConfig(),
|
|
||||||
overridePolicy:
|
|
||||||
(settings.overridePolicy as Record<string, unknown>) ?? {
|
|
||||||
allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditPipelinePage() {
|
export default async function EditPipelinePage({ params }: EditPipelinePageProps) {
|
||||||
const router = useRouter()
|
const { id } = await params
|
||||||
const params = useParams()
|
// Editing now happens inline on the detail page
|
||||||
const pipelineId = params.id as string
|
redirect(`/admin/rounds/pipeline/${id}` as never)
|
||||||
|
|
||||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
|
|
||||||
id: pipelineId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [state, setState] = useState<WizardState | null>(null)
|
|
||||||
const [openSection, setOpenSection] = useState(0)
|
|
||||||
const initialStateRef = useRef<string>('')
|
|
||||||
|
|
||||||
// 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<WizardState>) => {
|
|
||||||
setState((prev) => (prev ? { ...prev, ...updates } : null))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const updateStageConfig = useCallback(
|
|
||||||
(stageType: string, configJson: Record<string, unknown>) => {
|
|
||||||
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 (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Skeleton className="h-8 w-8" />
|
|
||||||
<div>
|
|
||||||
<Skeleton className="h-6 w-48" />
|
|
||||||
<Skeleton className="h-4 w-32 mt-1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Skeleton key={i} className="h-16 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href={`/admin/rounds/pipeline/${pipelineId}` as Route}>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Edit Pipeline</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{pipeline?.name}
|
|
||||||
{isActive && ' (Active — some fields are locked)'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={isSaving || !allValid}
|
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Wizard Sections */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={1}
|
|
||||||
title={sections[0].title}
|
|
||||||
description={sections[0].description}
|
|
||||||
isOpen={openSection === 0}
|
|
||||||
onToggle={() => setOpenSection(openSection === 0 ? -1 : 0)}
|
|
||||||
isValid={sections[0].isValid}
|
|
||||||
>
|
|
||||||
<BasicsSection state={state} onChange={updateState} isActive={isActive} />
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={2}
|
|
||||||
title={sections[1].title}
|
|
||||||
description={sections[1].description}
|
|
||||||
isOpen={openSection === 1}
|
|
||||||
onToggle={() => setOpenSection(openSection === 1 ? -1 : 1)}
|
|
||||||
isValid={sections[1].isValid}
|
|
||||||
>
|
|
||||||
<IntakeSection
|
|
||||||
config={intakeConfig}
|
|
||||||
onChange={(c) =>
|
|
||||||
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
isActive={isActive}
|
|
||||||
/>
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={3}
|
|
||||||
title={sections[2].title}
|
|
||||||
description={sections[2].description}
|
|
||||||
isOpen={openSection === 2}
|
|
||||||
onToggle={() => setOpenSection(openSection === 2 ? -1 : 2)}
|
|
||||||
isValid={sections[2].isValid}
|
|
||||||
>
|
|
||||||
<MainTrackSection
|
|
||||||
stages={mainTrack?.stages ?? []}
|
|
||||||
onChange={updateMainTrackStages}
|
|
||||||
isActive={isActive}
|
|
||||||
/>
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={4}
|
|
||||||
title={sections[3].title}
|
|
||||||
description={sections[3].description}
|
|
||||||
isOpen={openSection === 3}
|
|
||||||
onToggle={() => setOpenSection(openSection === 3 ? -1 : 3)}
|
|
||||||
isValid={sections[3].isValid}
|
|
||||||
>
|
|
||||||
<FilteringSection
|
|
||||||
config={filterConfig}
|
|
||||||
onChange={(c) =>
|
|
||||||
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
isActive={isActive}
|
|
||||||
/>
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={5}
|
|
||||||
title={sections[4].title}
|
|
||||||
description={sections[4].description}
|
|
||||||
isOpen={openSection === 4}
|
|
||||||
onToggle={() => setOpenSection(openSection === 4 ? -1 : 4)}
|
|
||||||
isValid={sections[4].isValid}
|
|
||||||
>
|
|
||||||
<AssignmentSection
|
|
||||||
config={evalConfig}
|
|
||||||
onChange={(c) =>
|
|
||||||
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
isActive={isActive}
|
|
||||||
/>
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={6}
|
|
||||||
title={sections[5].title}
|
|
||||||
description={sections[5].description}
|
|
||||||
isOpen={openSection === 5}
|
|
||||||
onToggle={() => setOpenSection(openSection === 5 ? -1 : 5)}
|
|
||||||
isValid={sections[5].isValid}
|
|
||||||
>
|
|
||||||
<AwardsSection
|
|
||||||
tracks={state.tracks}
|
|
||||||
onChange={(tracks) => updateState({ tracks })}
|
|
||||||
isActive={isActive}
|
|
||||||
/>
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={7}
|
|
||||||
title={sections[6].title}
|
|
||||||
description={sections[6].description}
|
|
||||||
isOpen={openSection === 6}
|
|
||||||
onToggle={() => setOpenSection(openSection === 6 ? -1 : 6)}
|
|
||||||
isValid={sections[6].isValid}
|
|
||||||
>
|
|
||||||
<LiveFinalsSection
|
|
||||||
config={liveConfig}
|
|
||||||
onChange={(c) =>
|
|
||||||
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
isActive={isActive}
|
|
||||||
/>
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={8}
|
|
||||||
title={sections[7].title}
|
|
||||||
description={sections[7].description}
|
|
||||||
isOpen={openSection === 7}
|
|
||||||
onToggle={() => setOpenSection(openSection === 7 ? -1 : 7)}
|
|
||||||
isValid={sections[7].isValid}
|
|
||||||
>
|
|
||||||
<NotificationsSection
|
|
||||||
config={state.notificationConfig}
|
|
||||||
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
|
||||||
overridePolicy={state.overridePolicy}
|
|
||||||
onOverridePolicyChange={(overridePolicy) =>
|
|
||||||
updateState({ overridePolicy })
|
|
||||||
}
|
|
||||||
isActive={isActive}
|
|
||||||
/>
|
|
||||||
</WizardSection>
|
|
||||||
|
|
||||||
<WizardSection
|
|
||||||
stepNumber={9}
|
|
||||||
title={sections[8].title}
|
|
||||||
description={sections[8].description}
|
|
||||||
isOpen={openSection === 8}
|
|
||||||
onToggle={() => setOpenSection(openSection === 8 ? -1 : 8)}
|
|
||||||
isValid={sections[8].isValid}
|
|
||||||
>
|
|
||||||
<ReviewSection state={state} />
|
|
||||||
</WizardSection>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
|
|
@ -10,12 +10,8 @@ import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -27,7 +23,6 @@ import { toast } from 'sonner'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Edit,
|
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Rocket,
|
Rocket,
|
||||||
Archive,
|
Archive,
|
||||||
|
|
@ -35,8 +30,14 @@ import {
|
||||||
Layers,
|
Layers,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
ChevronDown,
|
||||||
} from 'lucide-react'
|
} 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 { IntakePanel } from '@/components/admin/pipeline/stage-panels/intake-panel'
|
||||||
import { FilterPanel } from '@/components/admin/pipeline/stage-panels/filter-panel'
|
import { FilterPanel } from '@/components/admin/pipeline/stage-panels/filter-panel'
|
||||||
import { EvaluationPanel } from '@/components/admin/pipeline/stage-panels/evaluation-panel'
|
import { EvaluationPanel } from '@/components/admin/pipeline/stage-panels/evaluation-panel'
|
||||||
|
|
@ -51,15 +52,6 @@ const statusColors: Record<string, string> = {
|
||||||
CLOSED: 'bg-blue-100 text-blue-700',
|
CLOSED: 'bg-blue-100 text-blue-700',
|
||||||
}
|
}
|
||||||
|
|
||||||
const stageTypeColors: Record<string, string> = {
|
|
||||||
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({
|
function StagePanel({
|
||||||
stageId,
|
stageId,
|
||||||
stageType,
|
stageType,
|
||||||
|
|
@ -100,20 +92,14 @@ 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 stagePanelRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
|
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
|
||||||
id: pipelineId,
|
id: pipelineId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-select first track and stage
|
const { isUpdating, updatePipeline, updateStageConfig } =
|
||||||
useEffect(() => {
|
usePipelineInlineEdit(pipelineId)
|
||||||
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 publishMutation = trpc.pipeline.publish.useMutation({
|
const publishMutation = trpc.pipeline.publish.useMutation({
|
||||||
onSuccess: () => toast.success('Pipeline published'),
|
onSuccess: () => toast.success('Pipeline published'),
|
||||||
|
|
@ -125,6 +111,25 @@ export default function PipelineDetailPage() {
|
||||||
onError: (err) => toast.error(err.message),
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -170,12 +175,27 @@ export default function PipelineDetailPage() {
|
||||||
setSelectedTrackId(trackId)
|
setSelectedTrackId(trackId)
|
||||||
const track = pipeline.tracks.find((t) => t.id === trackId)
|
const track = pipeline.tracks.find((t) => t.id === trackId)
|
||||||
if (track && track.stages.length > 0) {
|
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 {
|
} else {
|
||||||
setSelectedStageId(null)
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -188,30 +208,69 @@ export default function PipelineDetailPage() {
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-xl font-bold">{pipeline.name}</h1>
|
<InlineEditableText
|
||||||
<Badge
|
value={pipeline.name}
|
||||||
variant="secondary"
|
onSave={(newName) => updatePipeline({ name: newName })}
|
||||||
className={cn(
|
variant="h1"
|
||||||
'text-[10px]',
|
placeholder="Untitled Pipeline"
|
||||||
statusColors[pipeline.status] ?? ''
|
disabled={isUpdating}
|
||||||
)}
|
/>
|
||||||
>
|
<DropdownMenu>
|
||||||
{pipeline.status}
|
<DropdownMenuTrigger asChild>
|
||||||
</Badge>
|
<button
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors',
|
||||||
|
statusColors[pipeline.status] ?? '',
|
||||||
|
'hover:opacity-80'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{pipeline.status}
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleStatusChange('DRAFT')}
|
||||||
|
disabled={pipeline.status === 'DRAFT' || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
Draft
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleStatusChange('ACTIVE')}
|
||||||
|
disabled={pipeline.status === 'ACTIVE' || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleStatusChange('CLOSED')}
|
||||||
|
disabled={pipeline.status === 'CLOSED' || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
Closed
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleStatusChange('ARCHIVED')}
|
||||||
|
disabled={pipeline.status === 'ARCHIVED' || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
Archived
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<span className="text-muted-foreground">slug:</span>
|
||||||
|
<InlineEditableText
|
||||||
|
value={pipeline.slug}
|
||||||
|
onSave={(newSlug) => updatePipeline({ slug: newSlug })}
|
||||||
|
variant="mono"
|
||||||
|
placeholder="pipeline-slug"
|
||||||
|
disabled={isUpdating}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground font-mono">
|
|
||||||
{pipeline.slug}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link href={`/admin/rounds/pipeline/${pipelineId}/edit` as Route}>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Edit className="h-4 w-4 mr-1" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Link href={`/admin/rounds/pipeline/${pipelineId}/advanced` as Route}>
|
<Link href={`/admin/rounds/pipeline/${pipelineId}/advanced` as Route}>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<Settings2 className="h-4 w-4 mr-1" />
|
<Settings2 className="h-4 w-4 mr-1" />
|
||||||
|
|
@ -228,9 +287,7 @@ export default function PipelineDetailPage() {
|
||||||
{pipeline.status === 'DRAFT' && (
|
{pipeline.status === 'DRAFT' && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={publishMutation.isPending}
|
disabled={publishMutation.isPending}
|
||||||
onClick={() =>
|
onClick={() => publishMutation.mutate({ id: pipelineId })}
|
||||||
publishMutation.mutate({ id: pipelineId })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{publishMutation.isPending ? (
|
{publishMutation.isPending ? (
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
|
@ -243,12 +300,7 @@ export default function PipelineDetailPage() {
|
||||||
{pipeline.status === 'ACTIVE' && (
|
{pipeline.status === 'ACTIVE' && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={updateMutation.isPending}
|
disabled={updateMutation.isPending}
|
||||||
onClick={() =>
|
onClick={() => handleStatusChange('CLOSED')}
|
||||||
updateMutation.mutate({
|
|
||||||
id: pipelineId,
|
|
||||||
status: 'CLOSED',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Close Pipeline
|
Close Pipeline
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -256,12 +308,7 @@ export default function PipelineDetailPage() {
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={updateMutation.isPending}
|
disabled={updateMutation.isPending}
|
||||||
onClick={() =>
|
onClick={() => handleStatusChange('ARCHIVED')}
|
||||||
updateMutation.mutate({
|
|
||||||
id: pipelineId,
|
|
||||||
status: 'ARCHIVED',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Archive className="h-4 w-4 mr-2" />
|
<Archive className="h-4 w-4 mr-2" />
|
||||||
Archive
|
Archive
|
||||||
|
|
@ -320,120 +367,91 @@ export default function PipelineDetailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Track Tabs */}
|
{/* Track Switcher (only if multiple tracks) */}
|
||||||
{pipeline.tracks.length > 0 && (
|
{pipeline.tracks.length > 1 && (
|
||||||
<Tabs
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
value={selectedTrackId ?? undefined}
|
{pipeline.tracks
|
||||||
onValueChange={handleTrackChange}
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
>
|
.map((track) => (
|
||||||
<TabsList className="w-full justify-start overflow-x-auto">
|
<button
|
||||||
{pipeline.tracks
|
key={track.id}
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
onClick={() => handleTrackChange(track.id)}
|
||||||
.map((track) => (
|
className={cn(
|
||||||
<TabsTrigger
|
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||||||
key={track.id}
|
selectedTrackId === track.id
|
||||||
value={track.id}
|
? 'bg-primary text-primary-foreground'
|
||||||
className="flex items-center gap-1.5"
|
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
|
||||||
>
|
)}
|
||||||
<span>{track.name}</span>
|
>
|
||||||
<Badge
|
<span>{track.name}</span>
|
||||||
variant="outline"
|
<Badge
|
||||||
className="text-[9px] h-4 px-1"
|
variant="outline"
|
||||||
>
|
className={cn(
|
||||||
{track.kind}
|
'text-[9px] h-4 px-1',
|
||||||
</Badge>
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{pipeline.tracks.map((track) => (
|
|
||||||
<TabsContent key={track.id} value={track.id} className="mt-4">
|
|
||||||
{/* Track Info */}
|
|
||||||
<Card className="mb-4">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-sm">{track.name}</CardTitle>
|
|
||||||
<CardDescription className="font-mono text-xs">
|
|
||||||
{track.slug}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{track.routingMode && (
|
|
||||||
<Badge variant="outline" className="text-[10px]">
|
|
||||||
{track.routingMode}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{track.decisionMode && (
|
|
||||||
<Badge variant="outline" className="text-[10px]">
|
|
||||||
{track.decisionMode}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Stage Tabs within Track */}
|
|
||||||
{track.stages.length > 0 ? (
|
|
||||||
<Tabs
|
|
||||||
value={
|
|
||||||
selectedTrackId === track.id
|
selectedTrackId === track.id
|
||||||
? selectedStageId ?? undefined
|
? 'border-primary-foreground/20 text-primary-foreground/80'
|
||||||
: undefined
|
: ''
|
||||||
}
|
)}
|
||||||
onValueChange={setSelectedStageId}
|
|
||||||
>
|
>
|
||||||
<TabsList className="w-full justify-start overflow-x-auto">
|
{track.kind}
|
||||||
{track.stages
|
</Badge>
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
</button>
|
||||||
.map((stage) => (
|
))}
|
||||||
<TabsTrigger
|
</div>
|
||||||
key={stage.id}
|
|
||||||
value={stage.id}
|
|
||||||
className="flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<span>{stage.name}</span>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={cn(
|
|
||||||
'text-[9px] h-4 px-1',
|
|
||||||
stageTypeColors[stage.stageType] ?? ''
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{stage.stageType.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{track.stages.map((stage) => (
|
|
||||||
<TabsContent
|
|
||||||
key={stage.id}
|
|
||||||
value={stage.id}
|
|
||||||
className="mt-4"
|
|
||||||
>
|
|
||||||
<StagePanel
|
|
||||||
stageId={stage.id}
|
|
||||||
stageType={stage.stageType}
|
|
||||||
configJson={
|
|
||||||
stage.configJson as Record<string, unknown> | null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
No stages configured for this track
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pipeline Flowchart */}
|
||||||
|
{flowchartTracks.length > 0 ? (
|
||||||
|
<PipelineFlowchart
|
||||||
|
tracks={flowchartTracks}
|
||||||
|
selectedStageId={selectedStageId}
|
||||||
|
onStageSelect={handleStageSelect}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No tracks configured for this pipeline
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected Stage Detail */}
|
||||||
|
<div ref={stagePanelRef}>
|
||||||
|
{selectedStage ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h2 className="text-lg font-semibold text-muted-foreground">
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stage Activity Panel */}
|
||||||
|
<StagePanel
|
||||||
|
stageId={selectedStage.id}
|
||||||
|
stageType={selectedStage.stageType}
|
||||||
|
configJson={selectedStage.configJson as Record<string, unknown> | null}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
|
@ -10,39 +8,45 @@ import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
MoreHorizontal,
|
|
||||||
Eye,
|
|
||||||
Edit,
|
|
||||||
Layers,
|
Layers,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
Workflow,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { format } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { useEdition } from '@/contexts/edition-context'
|
import { useEdition } from '@/contexts/edition-context'
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusConfig = {
|
||||||
DRAFT: 'bg-gray-100 text-gray-700',
|
DRAFT: {
|
||||||
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
label: 'Draft',
|
||||||
ARCHIVED: 'bg-muted text-muted-foreground',
|
bgClass: 'bg-gray-100 text-gray-700',
|
||||||
CLOSED: 'bg-blue-100 text-blue-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() {
|
export default function PipelineListPage() {
|
||||||
const router = useRouter()
|
|
||||||
const { currentEdition } = useEdition()
|
const { currentEdition } = useEdition()
|
||||||
const programId = currentEdition?.id
|
const programId = currentEdition?.id
|
||||||
|
|
||||||
|
|
@ -51,13 +55,6 @@ export default function PipelineListPage() {
|
||||||
{ enabled: !!programId }
|
{ 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) {
|
if (!programId) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -122,17 +119,20 @@ export default function PipelineListPage() {
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
{!isLoading && (!pipelines || pipelines.length === 0) && (
|
{!isLoading && (!pipelines || pipelines.length === 0) && (
|
||||||
<Card>
|
<Card className="border-2 border-dashed">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<GitBranch className="h-12 w-12 text-muted-foreground/50" />
|
<div className="rounded-full bg-primary/10 p-4 mb-4">
|
||||||
<p className="mt-2 font-medium">No Pipelines Yet</p>
|
<Workflow className="h-10 w-10 text-primary" />
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
</div>
|
||||||
Create your first pipeline to start managing project evaluation
|
<h3 className="text-lg font-semibold mb-2">No Pipelines Yet</h3>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
||||||
|
Pipelines organize your project evaluation workflow into tracks and stages.
|
||||||
|
Create your first pipeline to get started with managing project evaluations.
|
||||||
</p>
|
</p>
|
||||||
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
|
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
|
||||||
<Button size="sm">
|
<Button>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Create Pipeline
|
Create Your First Pipeline
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -142,80 +142,101 @@ export default function PipelineListPage() {
|
||||||
{/* Pipeline Cards */}
|
{/* Pipeline Cards */}
|
||||||
{pipelines && pipelines.length > 0 && (
|
{pipelines && pipelines.length > 0 && (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{pipelines.map((pipeline) => (
|
{pipelines.map((pipeline) => {
|
||||||
<Link
|
const status = pipeline.status as keyof typeof statusConfig
|
||||||
key={pipeline.id}
|
const config = statusConfig[status] || statusConfig.DRAFT
|
||||||
href={`/admin/rounds/pipeline/${pipeline.id}` as Route}
|
const description = (pipeline.settingsJson as Record<string, unknown> | null)?.description as string | undefined
|
||||||
className="block"
|
|
||||||
>
|
return (
|
||||||
<Card className="group hover:shadow-md transition-shadow cursor-pointer">
|
<Link
|
||||||
<CardHeader className="pb-3">
|
key={pipeline.id}
|
||||||
<div className="flex items-start justify-between">
|
href={`/admin/rounds/pipeline/${pipeline.id}` as Route}
|
||||||
<div className="min-w-0 flex-1">
|
className="block"
|
||||||
<CardTitle className="text-base truncate">
|
>
|
||||||
{pipeline.name}
|
<Card className="group hover:shadow-md transition-shadow cursor-pointer h-full flex flex-col">
|
||||||
</CardTitle>
|
<CardHeader className="pb-3">
|
||||||
<CardDescription className="font-mono text-xs">
|
<div className="flex items-start justify-between gap-3">
|
||||||
{pipeline.slug}
|
<div className="min-w-0 flex-1">
|
||||||
</CardDescription>
|
<CardTitle className="text-base leading-tight mb-1">
|
||||||
</div>
|
{pipeline.name}
|
||||||
<div className="flex items-center gap-2">
|
</CardTitle>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground truncate">
|
||||||
|
{pipeline.slug}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10px] shrink-0',
|
'text-[10px] shrink-0 flex items-center gap-1.5',
|
||||||
statusColors[pipeline.status] ?? ''
|
config.bgClass
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pipeline.status}
|
<span className={cn('h-1.5 w-1.5 rounded-full', config.dotClass)} />
|
||||||
|
{config.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/admin/rounds/pipeline/${pipeline.id}` as Route}>
|
|
||||||
<Eye className="h-4 w-4 mr-2" />
|
|
||||||
View
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route}>
|
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
{/* Description */}
|
||||||
<CardContent>
|
{description && (
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
<p className="text-xs text-muted-foreground line-clamp-2 mt-2">
|
||||||
<div className="flex items-center gap-1">
|
{description}
|
||||||
<Layers className="h-3.5 w-3.5" />
|
</p>
|
||||||
<span>{pipeline._count.tracks} tracks</span>
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="mt-auto">
|
||||||
|
{/* Track Indicator - Simplified visualization */}
|
||||||
|
<div className="mb-3 pb-3 border-b">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1.5">
|
||||||
|
<Layers className="h-3.5 w-3.5" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{pipeline._count.tracks === 0
|
||||||
|
? 'No tracks'
|
||||||
|
: pipeline._count.tracks === 1
|
||||||
|
? '1 track'
|
||||||
|
: `${pipeline._count.tracks} tracks`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{pipeline._count.tracks > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: Math.min(pipeline._count.tracks, 5) }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-6 flex-1 rounded border border-border bg-muted/30 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div className="h-1 w-1 rounded-full bg-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{pipeline._count.tracks > 5 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-1">
|
||||||
|
+{pipeline._count.tracks - 5}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<GitBranch className="h-3.5 w-3.5" />
|
{/* Stats */}
|
||||||
<span>{pipeline._count.routingRules} rules</span>
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
|
<span>Routing rules</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{pipeline._count.routingRules}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Updated {formatDistanceToNow(new Date(pipeline.updatedAt))} ago</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
</Card>
|
||||||
Created {format(new Date(pipeline.createdAt), 'MMM d, yyyy')}
|
</Link>
|
||||||
</p>
|
)
|
||||||
</CardContent>
|
})}
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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<string, { bg: string; border: string; text: string; glow: string }> = {
|
||||||
|
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<HTMLDivElement>(null)
|
||||||
|
const [hoveredStageId, setHoveredStageId] = useState<string | null>(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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn('overflow-x-auto rounded-lg border bg-card', className)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width={totalWidth}
|
||||||
|
height={totalHeight}
|
||||||
|
viewBox={`0 0 ${totalWidth} ${totalHeight}`}
|
||||||
|
className="min-w-full"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id="arrowhead"
|
||||||
|
markerWidth={ARROW_SIZE}
|
||||||
|
markerHeight={ARROW_SIZE}
|
||||||
|
refX={ARROW_SIZE}
|
||||||
|
refY={ARROW_SIZE / 2}
|
||||||
|
orient="auto"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d={`M 0 0 L ${ARROW_SIZE} ${ARROW_SIZE / 2} L 0 ${ARROW_SIZE} Z`}
|
||||||
|
fill="#94a3b8"
|
||||||
|
/>
|
||||||
|
</marker>
|
||||||
|
{/* Glow filter for selected node */}
|
||||||
|
<filter id="selectedGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||||
|
<feFlood floodColor="#3b82f6" floodOpacity="0.3" result="color" />
|
||||||
|
<feComposite in="color" in2="blur" operator="in" result="glow" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="glow" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<g key={track.id}>
|
||||||
|
{/* Track label */}
|
||||||
|
<text
|
||||||
|
x={20}
|
||||||
|
y={trackY + 14}
|
||||||
|
className="fill-muted-foreground text-[11px] font-medium"
|
||||||
|
style={{ fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{track.name}
|
||||||
|
{track.kind !== 'MAIN' && ` (${track.kind})`}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<line
|
||||||
|
key={`arrow-${stage.id}`}
|
||||||
|
x1={from.x + nodeW}
|
||||||
|
y1={arrowY}
|
||||||
|
x2={to.x - 2}
|
||||||
|
y2={arrowY}
|
||||||
|
stroke="#94a3b8"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
markerEnd="url(#arrowhead)"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<g
|
||||||
|
key={stage.id}
|
||||||
|
onClick={() => onStageSelect?.(stage.id)}
|
||||||
|
onMouseEnter={() => setHoveredStageId(stage.id)}
|
||||||
|
onMouseLeave={() => setHoveredStageId(null)}
|
||||||
|
className={cn(onStageSelect && 'cursor-pointer')}
|
||||||
|
filter={isSelected ? 'url(#selectedGlow)' : undefined}
|
||||||
|
>
|
||||||
|
{/* Selection ring */}
|
||||||
|
{isSelected && (
|
||||||
|
<motion.rect
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
x={pos.x - 3}
|
||||||
|
y={pos.y - 3}
|
||||||
|
width={nodeW + 6}
|
||||||
|
height={nodeH + 6}
|
||||||
|
rx={10}
|
||||||
|
fill="none"
|
||||||
|
stroke={colors.glow}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Node background */}
|
||||||
|
<rect
|
||||||
|
x={pos.x}
|
||||||
|
y={pos.y}
|
||||||
|
width={nodeW}
|
||||||
|
height={nodeH}
|
||||||
|
rx={8}
|
||||||
|
fill={colors.bg}
|
||||||
|
stroke={isSelected ? colors.glow : colors.border}
|
||||||
|
strokeWidth={isSelected ? 2 : 1}
|
||||||
|
style={{
|
||||||
|
transition: 'stroke 0.15s, stroke-width 0.15s',
|
||||||
|
transform: isHovered && !isSelected ? 'scale(1.02)' : undefined,
|
||||||
|
transformOrigin: `${pos.x + nodeW / 2}px ${pos.y + nodeH / 2}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stage name */}
|
||||||
|
<text
|
||||||
|
x={pos.x + nodeW / 2}
|
||||||
|
y={pos.y + (compact ? 20 : 24)}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={colors.text}
|
||||||
|
className={cn(compact ? 'text-[10px]' : 'text-xs', 'font-medium')}
|
||||||
|
style={{ fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{stage.name.length > (compact ? 12 : 16)
|
||||||
|
? stage.name.slice(0, compact ? 10 : 14) + '...'
|
||||||
|
: stage.name}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Type badge */}
|
||||||
|
<text
|
||||||
|
x={pos.x + nodeW / 2}
|
||||||
|
y={pos.y + (compact ? 34 : 40)}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={colors.text}
|
||||||
|
className="text-[9px]"
|
||||||
|
style={{ fontFamily: 'inherit', opacity: 0.7 }}
|
||||||
|
>
|
||||||
|
{stage.stageType.replace('_', ' ')}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Project count */}
|
||||||
|
{!compact && projectCount > 0 && (
|
||||||
|
<>
|
||||||
|
<rect
|
||||||
|
x={pos.x + nodeW / 2 - 14}
|
||||||
|
y={pos.y + nodeH - 18}
|
||||||
|
width={28}
|
||||||
|
height={14}
|
||||||
|
rx={7}
|
||||||
|
fill={colors.border}
|
||||||
|
opacity={0.3}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={pos.x + nodeW / 2}
|
||||||
|
y={pos.y + nodeH - 8}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={colors.text}
|
||||||
|
className="text-[9px] font-medium"
|
||||||
|
style={{ fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{projectCount}
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<string, unknown> | null
|
||||||
|
onSave: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
|
||||||
|
isSaving?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const stageIcons: Record<string, React.ReactNode> = {
|
||||||
|
INTAKE: <Inbox className="h-4 w-4" />,
|
||||||
|
FILTER: <Filter className="h-4 w-4" />,
|
||||||
|
EVALUATION: <ClipboardCheck className="h-4 w-4" />,
|
||||||
|
SELECTION: <Trophy className="h-4 w-4" />,
|
||||||
|
LIVE_FINAL: <Tv className="h-4 w-4" />,
|
||||||
|
RESULTS: <BarChart3 className="h-4 w-4" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigSummary({
|
||||||
|
stageType,
|
||||||
|
configJson,
|
||||||
|
}: {
|
||||||
|
stageType: string
|
||||||
|
configJson: Record<string, unknown> | null
|
||||||
|
}) {
|
||||||
|
if (!configJson) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
No configuration set
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (stageType) {
|
||||||
|
case 'INTAKE': {
|
||||||
|
const config = configJson as unknown as IntakeConfig
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Submission Window:</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{config.submissionWindowEnabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Late Policy:</span>
|
||||||
|
<span className="capitalize">{config.lateSubmissionPolicy}</span>
|
||||||
|
{config.lateGraceHours > 0 && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({config.lateGraceHours}h grace)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">File Requirements:</span>
|
||||||
|
<span>{config.fileRequirements?.length ?? 0} configured</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'FILTER': {
|
||||||
|
const config = configJson as unknown as FilterConfig
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Rules:</span>
|
||||||
|
<span>{config.rules?.length ?? 0} eligibility rules</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">AI Screening:</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{config.aiRubricEnabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{config.aiRubricEnabled && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Confidence:</span>
|
||||||
|
<span>
|
||||||
|
High {config.aiConfidenceThresholds?.high ?? 0.85} / Med{' '}
|
||||||
|
{config.aiConfidenceThresholds?.medium ?? 0.6}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Manual Queue:</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{config.manualQueueEnabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'EVALUATION': {
|
||||||
|
const config = configJson as unknown as EvaluationConfig
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Required Reviews:</span>
|
||||||
|
<span>{config.requiredReviews ?? 3}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Load per Juror:</span>
|
||||||
|
<span>
|
||||||
|
{config.minLoadPerJuror ?? 5} - {config.maxLoadPerJuror ?? 20}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Overflow Policy:</span>
|
||||||
|
<span className="capitalize">
|
||||||
|
{(config.overflowPolicy ?? 'queue').replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SELECTION': {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Ranking Method:</span>
|
||||||
|
<span className="capitalize">
|
||||||
|
{((configJson.rankingMethod as string) ?? 'score_average').replace(
|
||||||
|
/_/g,
|
||||||
|
' '
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Tie Breaker:</span>
|
||||||
|
<span className="capitalize">
|
||||||
|
{((configJson.tieBreaker as string) ?? 'admin_decides').replace(
|
||||||
|
/_/g,
|
||||||
|
' '
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{configJson.finalistCount != null && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Finalist Count:</span>
|
||||||
|
<span>{String(configJson.finalistCount)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'LIVE_FINAL': {
|
||||||
|
const config = configJson as unknown as LiveFinalConfig
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Jury Voting:</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{config.juryVotingEnabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Audience Voting:</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{config.audienceVotingEnabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
{config.audienceVotingEnabled && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({config.audienceVoteWeight}% weight)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Reveal:</span>
|
||||||
|
<span className="capitalize">{config.revealPolicy ?? 'ceremony'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'RESULTS': {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Publication:</span>
|
||||||
|
<span className="capitalize">
|
||||||
|
{((configJson.publicationMode as string) ?? 'manual').replace(
|
||||||
|
/_/g,
|
||||||
|
' '
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Show Scores:</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{configJson.showDetailedScores ? 'Yes' : 'No'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
Configuration view not available for this stage type
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StageConfigEditor({
|
||||||
|
stageId,
|
||||||
|
stageName,
|
||||||
|
stageType,
|
||||||
|
configJson,
|
||||||
|
onSave,
|
||||||
|
isSaving = false,
|
||||||
|
}: StageConfigEditorProps) {
|
||||||
|
const [localConfig, setLocalConfig] = useState<Record<string, unknown>>(
|
||||||
|
() => 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 (
|
||||||
|
<IntakeSection
|
||||||
|
config={config}
|
||||||
|
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'FILTER': {
|
||||||
|
const config = {
|
||||||
|
...defaultFilterConfig(),
|
||||||
|
...(localConfig as object),
|
||||||
|
} as FilterConfig
|
||||||
|
return (
|
||||||
|
<FilteringSection
|
||||||
|
config={config}
|
||||||
|
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'EVALUATION': {
|
||||||
|
const config = {
|
||||||
|
...defaultEvaluationConfig(),
|
||||||
|
...(localConfig as object),
|
||||||
|
} as EvaluationConfig
|
||||||
|
return (
|
||||||
|
<AssignmentSection
|
||||||
|
config={config}
|
||||||
|
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'LIVE_FINAL': {
|
||||||
|
const config = {
|
||||||
|
...defaultLiveConfig(),
|
||||||
|
...(localConfig as object),
|
||||||
|
} as LiveFinalConfig
|
||||||
|
return (
|
||||||
|
<LiveFinalsSection
|
||||||
|
config={config}
|
||||||
|
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'SELECTION':
|
||||||
|
case 'RESULTS':
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground py-4 text-center">
|
||||||
|
Configuration for {stageType.replace('_', ' ')} stages is managed
|
||||||
|
through the stage settings.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditableCard
|
||||||
|
title={`${stageName} Configuration`}
|
||||||
|
icon={stageIcons[stageType]}
|
||||||
|
summary={<ConfigSummary stageType={stageType} configJson={configJson} />}
|
||||||
|
onSave={handleSave}
|
||||||
|
isSaving={isSaving}
|
||||||
|
>
|
||||||
|
{renderEditor()}
|
||||||
|
</EditableCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
|
||||||
<Collapsible open={isOpen} onOpenChange={onToggle}>
|
|
||||||
<Card className={cn(isOpen && 'ring-1 ring-ring')}>
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<CardHeader className="cursor-pointer select-none hover:bg-muted/50 transition-colors">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Badge
|
|
||||||
variant={isValid ? 'default' : 'outline'}
|
|
||||||
className={cn(
|
|
||||||
'h-7 w-7 shrink-0 rounded-full p-0 flex items-center justify-center text-xs font-bold',
|
|
||||||
isValid
|
|
||||||
? 'bg-emerald-500 text-white hover:bg-emerald-500'
|
|
||||||
: hasErrors
|
|
||||||
? 'border-destructive text-destructive'
|
|
||||||
: ''
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isValid ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
|
||||||
) : hasErrors ? (
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
stepNumber
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-sm font-semibold">{title}</h3>
|
|
||||||
{description && !isOpen && (
|
|
||||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
'h-4 w-4 text-muted-foreground transition-transform',
|
|
||||||
isOpen && 'rotate-180'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
{helpText && (
|
|
||||||
<div className="bg-blue-50 text-blue-700 dark:bg-blue-950/30 dark:text-blue-300 text-sm rounded-md p-3 mb-4 flex items-start gap-2">
|
|
||||||
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
|
||||||
<span>{helpText}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</CardContent>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Card>
|
|
||||||
</Collapsible>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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<void>
|
||||||
|
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 (
|
||||||
|
<Card className={cn('group relative overflow-hidden', className)}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{icon && (
|
||||||
|
<span className="text-muted-foreground">{icon}</span>
|
||||||
|
)}
|
||||||
|
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||||
|
</div>
|
||||||
|
{!isEditing && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className={cn(
|
||||||
|
'h-7 gap-1.5 text-xs',
|
||||||
|
!alwaysShowEdit && 'opacity-0 group-hover:opacity-100 transition-opacity'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isEditing && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="h-7 gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
{isEditing ? (
|
||||||
|
<motion.div
|
||||||
|
key="edit"
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{children}
|
||||||
|
{onSave && (
|
||||||
|
<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>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="view"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
>
|
||||||
|
{summary}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<void>
|
||||||
|
variant?: InlineEditableTextVariant
|
||||||
|
placeholder?: string
|
||||||
|
multiline?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles: Record<InlineEditableTextVariant, string> = {
|
||||||
|
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<HTMLInputElement | HTMLTextAreaElement>(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 (
|
||||||
|
<span className={cn(variantStyles[variant], className)}>
|
||||||
|
{value || <span className="text-muted-foreground italic">{placeholder}</span>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
{isEditing ? (
|
||||||
|
<motion.div
|
||||||
|
key="editing"
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className={cn('flex items-center gap-1.5', className)}
|
||||||
|
>
|
||||||
|
{multiline ? (
|
||||||
|
<Textarea
|
||||||
|
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn(variantStyles[variant], 'min-h-[60px] resize-y')}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn(variantStyles[variant], 'h-auto py-1')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="shrink-0 rounded p-1 text-emerald-600 hover:bg-emerald-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.button
|
||||||
|
key="viewing"
|
||||||
|
type="button"
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className={cn(
|
||||||
|
'group inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 -mx-1.5 -my-0.5',
|
||||||
|
'hover:bg-muted/60 transition-colors text-left cursor-text',
|
||||||
|
variantStyles[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(!value && 'text-muted-foreground italic')}>
|
||||||
|
{value || placeholder}
|
||||||
|
</span>
|
||||||
|
<Pencil className="h-3 w-3 shrink-0 opacity-0 group-hover:opacity-50 transition-opacity" />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { CheckCircle2, AlertCircle, Loader2, Save, Rocket } from 'lucide-react'
|
||||||
|
|
||||||
|
export type StepConfig = {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
isValid?: boolean
|
||||||
|
hasErrors?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type SidebarStepperProps = {
|
||||||
|
steps: StepConfig[]
|
||||||
|
currentStep: number
|
||||||
|
onStepChange: (index: number) => void
|
||||||
|
onSave?: () => void
|
||||||
|
onSubmit?: () => void
|
||||||
|
isSaving?: boolean
|
||||||
|
isSubmitting?: boolean
|
||||||
|
saveLabel?: string
|
||||||
|
submitLabel?: string
|
||||||
|
canSubmit?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarStepper({
|
||||||
|
steps,
|
||||||
|
currentStep,
|
||||||
|
onStepChange,
|
||||||
|
onSave,
|
||||||
|
onSubmit,
|
||||||
|
isSaving = false,
|
||||||
|
isSubmitting = false,
|
||||||
|
saveLabel = 'Save Draft',
|
||||||
|
submitLabel = 'Create Pipeline',
|
||||||
|
canSubmit = true,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: SidebarStepperProps) {
|
||||||
|
const direction = (prev: number, next: number) => (next > prev ? 1 : -1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex gap-6 min-h-[600px]', className)}>
|
||||||
|
{/* Sidebar - hidden on mobile */}
|
||||||
|
<div className="hidden lg:flex lg:flex-col lg:w-[260px] lg:shrink-0">
|
||||||
|
<nav className="flex-1 space-y-1 py-2">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isCurrent = index === currentStep
|
||||||
|
const isComplete = step.isValid === true
|
||||||
|
const hasErrors = step.hasErrors === true
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStepChange(index)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-start gap-3 rounded-lg px-3 py-2.5 text-left transition-colors',
|
||||||
|
isCurrent
|
||||||
|
? 'bg-primary/5 border border-primary/20'
|
||||||
|
: 'hover:bg-muted/50 border border-transparent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold transition-colors',
|
||||||
|
isCurrent && 'bg-primary text-primary-foreground',
|
||||||
|
!isCurrent && isComplete && 'bg-emerald-500 text-white',
|
||||||
|
!isCurrent && hasErrors && 'bg-destructive/10 text-destructive border border-destructive/30',
|
||||||
|
!isCurrent && !isComplete && !hasErrors && 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isComplete && !isCurrent ? (
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
) : hasErrors && !isCurrent ? (
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
index + 1
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium truncate',
|
||||||
|
isCurrent && 'text-primary',
|
||||||
|
!isCurrent && 'text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.title}
|
||||||
|
</p>
|
||||||
|
{step.description && (
|
||||||
|
<p className="text-[11px] text-muted-foreground truncate mt-0.5">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="border-t pt-4 space-y-2 mt-auto">
|
||||||
|
{onSave && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start"
|
||||||
|
disabled={isSaving || isSubmitting}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{saveLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onSubmit && (
|
||||||
|
<Button
|
||||||
|
className="w-full justify-start"
|
||||||
|
disabled={isSubmitting || isSaving || !canSubmit}
|
||||||
|
onClick={onSubmit}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Rocket className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile step indicator */}
|
||||||
|
<div className="lg:hidden flex flex-col w-full">
|
||||||
|
<MobileStepIndicator
|
||||||
|
steps={steps}
|
||||||
|
currentStep={currentStep}
|
||||||
|
onStepChange={onStepChange}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 mt-4">
|
||||||
|
<StepContent currentStep={currentStep} direction={direction}>
|
||||||
|
{children}
|
||||||
|
</StepContent>
|
||||||
|
</div>
|
||||||
|
{/* Mobile actions */}
|
||||||
|
<div className="flex gap-2 pt-4 border-t mt-4">
|
||||||
|
{onSave && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isSaving || isSubmitting}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
{isSaving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
|
||||||
|
{saveLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onSubmit && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isSubmitting || isSaving || !canSubmit}
|
||||||
|
onClick={onSubmit}
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Rocket className="h-4 w-4 mr-1" />}
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop content */}
|
||||||
|
<div className="hidden lg:block flex-1 min-w-0">
|
||||||
|
<StepContent currentStep={currentStep} direction={direction}>
|
||||||
|
{children}
|
||||||
|
</StepContent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileStepIndicator({
|
||||||
|
steps,
|
||||||
|
currentStep,
|
||||||
|
onStepChange,
|
||||||
|
}: {
|
||||||
|
steps: StepConfig[]
|
||||||
|
currentStep: number
|
||||||
|
onStepChange: (index: number) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="flex items-center gap-1 pb-2 min-w-max">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isCurrent = index === currentStep
|
||||||
|
const isComplete = step.isValid === true
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStepChange(index)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-colors shrink-0',
|
||||||
|
isCurrent && 'bg-primary text-primary-foreground',
|
||||||
|
!isCurrent && isComplete && 'bg-emerald-100 text-emerald-700',
|
||||||
|
!isCurrent && !isComplete && 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isComplete && !isCurrent ? (
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<span>{index + 1}</span>
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">{step.title}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepContent({
|
||||||
|
currentStep,
|
||||||
|
direction,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
currentStep: number
|
||||||
|
direction: (prev: number, next: number) => number
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const childArray = Array.isArray(children) ? children : [children]
|
||||||
|
const currentChild = childArray[currentStep]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.div
|
||||||
|
key={currentStep}
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.2,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentChild}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export function usePipelineInlineEdit(pipelineId: string) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const updateMutation = trpc.pipeline.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.pipeline.getDraft.invalidate({ id: pipelineId })
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(`Failed to update pipeline: ${err.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateConfigMutation = trpc.stage.updateConfig.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.pipeline.getDraft.invalidate({ id: pipelineId })
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(`Failed to update stage: ${err.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatePipeline = async (
|
||||||
|
data: { name?: string; slug?: string; status?: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED'; settingsJson?: Record<string, unknown> }
|
||||||
|
) => {
|
||||||
|
await updateMutation.mutateAsync({ id: pipelineId, ...data })
|
||||||
|
toast.success('Pipeline updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateStageConfig = async (
|
||||||
|
stageId: string,
|
||||||
|
configJson: Record<string, unknown>,
|
||||||
|
name?: string
|
||||||
|
) => {
|
||||||
|
await updateConfigMutation.mutateAsync({ id: stageId, configJson, name })
|
||||||
|
toast.success('Stage configuration updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isUpdating: updateMutation.isPending || updateConfigMutation.isPending,
|
||||||
|
updatePipeline,
|
||||||
|
updateStageConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue