Pipeline UX: clickable cards, wizard edit, routing rules redesign, category quotas
All checks were successful
Build and Push Docker Image / build (push) Successful in 18s
All checks were successful
Build and Push Docker Image / build (push) Successful in 18s
- Simplify pipeline list cards: whole card is clickable, remove clutter - Add wizard edit page for existing pipelines with full state pre-population - Extract toWizardTrackConfig to shared utility for reuse - Rewrite predicate builder with 3 modes: Simple (sentence-style), AI (NLP), Advanced (JSON) - Fix routing operators to match backend (eq/neq/in/contains/gt/lt) - Rewrite routing rules editor with collapsible cards and natural language summaries - Add parseNaturalLanguageRule AI procedure for routing rules - Add per-category quotas to SelectionConfig and EvaluationConfig - Add category quota UI toggles to selection and assignment sections - Add category breakdown display to selection panel - Add category-aware scoring to smart assignment (penalty/bonus) - Add category-aware filtering targets with excess demotion Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
ArrowLeft,
|
||||
MoreHorizontal,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Save,
|
||||
Wand2,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { InlineEditableText } from '@/components/ui/inline-editable-text'
|
||||
@@ -41,8 +43,8 @@ import { AwardsSection } from '@/components/admin/pipeline/sections/awards-secti
|
||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
||||
import { RoutingRulesEditor } from '@/components/admin/pipeline/routing-rules-editor'
|
||||
import { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor'
|
||||
import { normalizeStageConfig } from '@/lib/stage-config-schema'
|
||||
import { defaultNotificationConfig } from '@/lib/pipeline-defaults'
|
||||
import { toWizardTrackConfig } from '@/lib/pipeline-conversions'
|
||||
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
@@ -52,71 +54,6 @@ const statusColors: Record<string, string> = {
|
||||
CLOSED: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
function toWizardTrackConfig(
|
||||
track: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
kind: 'MAIN' | 'AWARD' | 'SHOWCASE'
|
||||
sortOrder: number
|
||||
routingMode: 'PARALLEL' | 'EXCLUSIVE' | 'POST_MAIN' | null
|
||||
decisionMode:
|
||||
| 'JURY_VOTE'
|
||||
| 'AWARD_MASTER_DECISION'
|
||||
| 'ADMIN_DECISION'
|
||||
| null
|
||||
stages: Array<{
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
stageType:
|
||||
| 'INTAKE'
|
||||
| 'FILTER'
|
||||
| 'EVALUATION'
|
||||
| 'SELECTION'
|
||||
| 'LIVE_FINAL'
|
||||
| 'RESULTS'
|
||||
sortOrder: number
|
||||
configJson: unknown
|
||||
}>
|
||||
specialAward?: {
|
||||
name: string
|
||||
description: string | null
|
||||
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||
} | null
|
||||
}
|
||||
): WizardTrackConfig {
|
||||
return {
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
slug: track.slug,
|
||||
kind: track.kind,
|
||||
sortOrder: track.sortOrder,
|
||||
routingModeDefault: track.routingMode ?? undefined,
|
||||
decisionMode: track.decisionMode ?? undefined,
|
||||
stages: track.stages
|
||||
.map((stage) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
slug: stage.slug,
|
||||
stageType: stage.stageType,
|
||||
sortOrder: stage.sortOrder,
|
||||
configJson: normalizeStageConfig(
|
||||
stage.stageType,
|
||||
stage.configJson as Record<string, unknown> | null
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
awardConfig: track.specialAward
|
||||
? {
|
||||
name: track.specialAward.name,
|
||||
description: track.specialAward.description ?? undefined,
|
||||
scoringMode: track.specialAward.scoringMode,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export default function PipelineDetailPage() {
|
||||
const params = useParams()
|
||||
const pipelineId = params.id as string
|
||||
@@ -450,6 +387,13 @@ export default function PipelineDetailPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/pipeline/${pipelineId}/wizard` as Route}>
|
||||
<Wand2 className="h-4 w-4 mr-2" />
|
||||
Edit in Wizard
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{pipeline.status === 'DRAFT' && (
|
||||
<DropdownMenuItem
|
||||
disabled={publishMutation.isPending}
|
||||
@@ -660,10 +604,6 @@ export default function PipelineDetailPage() {
|
||||
{/* Routing Rules (only if multiple tracks) */}
|
||||
{hasMultipleTracks && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Routing Rules</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Define conditions for routing projects between tracks.
|
||||
</p>
|
||||
<RoutingRulesEditor
|
||||
pipelineId={pipelineId}
|
||||
tracks={trackOptionsForEditors}
|
||||
|
||||
410
src/app/(admin)/admin/rounds/pipeline/[id]/wizard/page.tsx
Normal file
410
src/app/(admin)/admin/rounds/pipeline/[id]/wizard/page.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
|
||||
import type { StepConfig } from '@/components/ui/sidebar-stepper'
|
||||
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
|
||||
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
||||
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 { defaultNotificationConfig, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
|
||||
import { toWizardTrackConfig } from '@/lib/pipeline-conversions'
|
||||
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
|
||||
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
export default function EditPipelineWizardPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const pipelineId = params.id as string
|
||||
|
||||
const [state, setState] = useState<WizardState | null>(null)
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const initialStateRef = useRef<string>('')
|
||||
|
||||
// Load existing pipeline data
|
||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery(
|
||||
{ id: pipelineId },
|
||||
{ enabled: !!pipelineId }
|
||||
)
|
||||
|
||||
// Initialize state when pipeline data loads
|
||||
useEffect(() => {
|
||||
if (pipeline && !state) {
|
||||
const settings = (pipeline.settingsJson as Record<string, unknown> | null) ?? {}
|
||||
const initialState: WizardState = {
|
||||
name: pipeline.name,
|
||||
slug: pipeline.slug,
|
||||
programId: pipeline.programId,
|
||||
settingsJson: settings,
|
||||
tracks: pipeline.tracks
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map(track => toWizardTrackConfig({
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
slug: track.slug,
|
||||
kind: track.kind as 'MAIN' | 'AWARD' | 'SHOWCASE',
|
||||
sortOrder: track.sortOrder,
|
||||
routingMode: track.routingMode as 'PARALLEL' | 'EXCLUSIVE' | 'POST_MAIN' | null,
|
||||
decisionMode: track.decisionMode as 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION' | null,
|
||||
stages: track.stages.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
stageType: s.stageType as 'INTAKE' | 'FILTER' | 'EVALUATION' | 'SELECTION' | 'LIVE_FINAL' | 'RESULTS',
|
||||
sortOrder: s.sortOrder,
|
||||
configJson: s.configJson,
|
||||
})),
|
||||
specialAward: track.specialAward ? {
|
||||
name: track.specialAward.name,
|
||||
description: track.specialAward.description,
|
||||
scoringMode: track.specialAward.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED',
|
||||
} : null,
|
||||
})),
|
||||
notificationConfig: (settings.notificationConfig as Record<string, boolean>) ?? defaultNotificationConfig(),
|
||||
overridePolicy: (settings.overridePolicy as Record<string, unknown>) ?? { allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
||||
}
|
||||
setState(initialState)
|
||||
initialStateRef.current = JSON.stringify(initialState)
|
||||
}
|
||||
}, [pipeline, state])
|
||||
|
||||
// Dirty tracking — warn on navigate away
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (state && 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 } : prev)
|
||||
}, [])
|
||||
|
||||
// Mutations
|
||||
const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const updateSettingsMutation = trpc.pipeline.update.useMutation({
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const publishMutation = trpc.pipeline.publish.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Pipeline published successfully')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = async (publish: boolean) => {
|
||||
if (!state) return
|
||||
|
||||
const validation = validateAll(state)
|
||||
if (!validation.valid) {
|
||||
toast.error('Please fix validation errors before saving')
|
||||
if (!validation.sections.basics.valid) setCurrentStep(0)
|
||||
else if (!validation.sections.tracks.valid) setCurrentStep(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,
|
||||
})
|
||||
|
||||
await updateSettingsMutation.mutateAsync({
|
||||
id: pipelineId,
|
||||
settingsJson: {
|
||||
...state.settingsJson,
|
||||
notificationConfig: state.notificationConfig,
|
||||
overridePolicy: state.overridePolicy,
|
||||
},
|
||||
})
|
||||
|
||||
if (publish) {
|
||||
await publishMutation.mutateAsync({ id: pipelineId })
|
||||
}
|
||||
|
||||
initialStateRef.current = JSON.stringify(state)
|
||||
toast.success(publish ? 'Pipeline saved and published' : 'Pipeline changes saved')
|
||||
router.push(`/admin/rounds/pipeline/${pipelineId}` as Route)
|
||||
}
|
||||
|
||||
const isSaving = updateStructureMutation.isPending && !publishMutation.isPending
|
||||
const isSubmitting = publishMutation.isPending
|
||||
|
||||
// Loading state
|
||||
if (isLoading || !state) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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 (Wizard)</h1>
|
||||
<p className="text-sm text-muted-foreground">Loading pipeline data...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Get stage configs from the main track
|
||||
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 = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
|
||||
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
|
||||
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
|
||||
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
|
||||
|
||||
const updateStageConfig = (stageType: string, configJson: Record<string, unknown>) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev
|
||||
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 = (stages: WizardState['tracks'][0]['stages']) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) =>
|
||||
track.kind === 'MAIN' ? { ...track, stages } : track
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Validation
|
||||
const basicsValid = validateBasics(state).valid
|
||||
const tracksValid = validateTracks(state.tracks).valid
|
||||
const allValid = validateAll(state).valid
|
||||
|
||||
// Step configuration
|
||||
const steps: StepConfig[] = [
|
||||
{
|
||||
title: 'Basics',
|
||||
description: 'Pipeline name and program',
|
||||
isValid: basicsValid,
|
||||
},
|
||||
{
|
||||
title: 'Intake',
|
||||
description: 'Submission window & files',
|
||||
isValid: !!intakeStage,
|
||||
},
|
||||
{
|
||||
title: 'Main Track Stages',
|
||||
description: 'Configure pipeline stages',
|
||||
isValid: tracksValid,
|
||||
},
|
||||
{
|
||||
title: 'Screening',
|
||||
description: 'Gate rules and AI screening',
|
||||
isValid: !!filterStage,
|
||||
},
|
||||
{
|
||||
title: 'Evaluation',
|
||||
description: 'Jury assignment strategy',
|
||||
isValid: !!evalStage,
|
||||
},
|
||||
{
|
||||
title: 'Awards',
|
||||
description: 'Special award tracks',
|
||||
isValid: true,
|
||||
},
|
||||
{
|
||||
title: 'Live Finals',
|
||||
description: 'Voting and reveal settings',
|
||||
isValid: !!liveStage,
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
description: 'Event notifications',
|
||||
isValid: true,
|
||||
},
|
||||
{
|
||||
title: 'Review & Save',
|
||||
description: 'Validation summary',
|
||||
isValid: allValid,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-8">
|
||||
{/* Header */}
|
||||
<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 (Wizard)</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Modify the pipeline structure for project evaluation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Stepper */}
|
||||
<SidebarStepper
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
onStepChange={setCurrentStep}
|
||||
onSave={() => handleSave(false)}
|
||||
onSubmit={() => handleSave(true)}
|
||||
isSaving={isSaving}
|
||||
isSubmitting={isSubmitting}
|
||||
saveLabel="Save Changes"
|
||||
submitLabel="Save & Publish"
|
||||
canSubmit={allValid}
|
||||
>
|
||||
{/* Step 0: Basics */}
|
||||
<div>
|
||||
<BasicsSection state={state} onChange={updateState} />
|
||||
</div>
|
||||
|
||||
{/* Step 1: Intake */}
|
||||
<div>
|
||||
<IntakeSection
|
||||
config={intakeConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Main Track Stages */}
|
||||
<div>
|
||||
<MainTrackSection
|
||||
stages={mainTrack?.stages ?? []}
|
||||
onChange={updateMainTrackStages}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 3: Screening */}
|
||||
<div>
|
||||
<FilteringSection
|
||||
config={filterConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 4: Evaluation */}
|
||||
<div>
|
||||
<AssignmentSection
|
||||
config={evalConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 5: Awards */}
|
||||
<div>
|
||||
<AwardsSection
|
||||
tracks={state.tracks}
|
||||
onChange={(tracks) => updateState({ tracks })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 6: Live Finals */}
|
||||
<div>
|
||||
<LiveFinalsSection
|
||||
config={liveConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 7: Notifications */}
|
||||
<div>
|
||||
<NotificationsSection
|
||||
config={state.notificationConfig}
|
||||
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
||||
overridePolicy={state.overridePolicy}
|
||||
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 8: Review & Save */}
|
||||
<div>
|
||||
<ReviewSection state={state} />
|
||||
</div>
|
||||
</SidebarStepper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,14 +12,12 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Plus,
|
||||
Layers,
|
||||
GitBranch,
|
||||
Calendar,
|
||||
Workflow,
|
||||
Pencil,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Plus,
|
||||
Layers,
|
||||
Calendar,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
@@ -148,19 +146,15 @@ export default function PipelineListPage() {
|
||||
const config = statusConfig[status] || statusConfig.DRAFT
|
||||
const description = (pipeline.settingsJson as Record<string, unknown> | null)?.description as string | undefined
|
||||
|
||||
return (
|
||||
<Card key={pipeline.id} className="group hover:shadow-md transition-shadow h-full flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base leading-tight mb-1">
|
||||
<Link href={`/admin/rounds/pipeline/${pipeline.id}` as Route} className="hover:underline">
|
||||
{pipeline.name}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<p className="font-mono text-xs text-muted-foreground truncate">
|
||||
{pipeline.slug}
|
||||
</p>
|
||||
return (
|
||||
<Link key={pipeline.id} href={`/admin/rounds/pipeline/${pipeline.id}` as Route}>
|
||||
<Card className="group cursor-pointer hover:shadow-md transition-shadow h-full flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base leading-tight">
|
||||
{pipeline.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@@ -174,7 +168,6 @@ export default function PipelineListPage() {
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-2">
|
||||
{description}
|
||||
@@ -182,10 +175,9 @@ export default function PipelineListPage() {
|
||||
)}
|
||||
</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">
|
||||
<CardContent className="mt-auto">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">
|
||||
{pipeline._count.tracks === 0
|
||||
@@ -195,56 +187,15 @@ export default function PipelineListPage() {
|
||||
: `${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>
|
||||
)}
|
||||
<span>Updated {formatDistanceToNow(new Date(pipeline.updatedAt))} ago</span>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<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 className="mt-3 flex items-center gap-2">
|
||||
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route} className="w-full">
|
||||
<Button size="sm" variant="outline" className="w-full">
|
||||
<Pencil className="h-3.5 w-3.5 mr-1.5" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user