Improve pipeline editor UX: stage detail sheet, structured predicates, page reorganization
Build and Push Docker Image / build (push) Failing after 40s
Details
Build and Push Docker Image / build (push) Failing after 40s
Details
- Add Sheet UI component and StageDetailSheet with config/activity tabs - Stage config opens in right-side sheet (always-editable, no collapsed summary) - Replace JSON textarea in routing rules with structured PredicateBuilder form - Remove StageTransitionsEditor from UI (transitions auto-managed) - Promote Stage Management section to immediately after flowchart - Conditionally hide Routing Rules (single track) and Award Governance (no awards) - Add section headers with descriptions and increase spacing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2d91ce02fc
commit
c321d4711e
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
|
@ -34,27 +34,17 @@ import {
|
||||||
|
|
||||||
import { InlineEditableText } from '@/components/ui/inline-editable-text'
|
import { InlineEditableText } from '@/components/ui/inline-editable-text'
|
||||||
import { PipelineFlowchart } from '@/components/admin/pipeline/pipeline-flowchart'
|
import { PipelineFlowchart } from '@/components/admin/pipeline/pipeline-flowchart'
|
||||||
import { StageConfigEditor } from '@/components/admin/pipeline/stage-config-editor'
|
import { StageDetailSheet } from '@/components/admin/pipeline/stage-detail-sheet'
|
||||||
import { usePipelineInlineEdit } from '@/hooks/use-pipeline-inline-edit'
|
import { usePipelineInlineEdit } from '@/hooks/use-pipeline-inline-edit'
|
||||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
||||||
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
||||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
||||||
import { FileRequirementsEditor } from '@/components/admin/file-requirements-editor'
|
|
||||||
import { FilteringRulesEditor } from '@/components/admin/pipeline/filtering-rules-editor'
|
|
||||||
import { RoutingRulesEditor } from '@/components/admin/pipeline/routing-rules-editor'
|
import { RoutingRulesEditor } from '@/components/admin/pipeline/routing-rules-editor'
|
||||||
import { StageTransitionsEditor } from '@/components/admin/pipeline/stage-transitions-editor'
|
|
||||||
import { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor'
|
import { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor'
|
||||||
import { normalizeStageConfig } from '@/lib/stage-config-schema'
|
import { normalizeStageConfig } from '@/lib/stage-config-schema'
|
||||||
import { defaultNotificationConfig } from '@/lib/pipeline-defaults'
|
import { defaultNotificationConfig } from '@/lib/pipeline-defaults'
|
||||||
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
|
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
|
||||||
|
|
||||||
import { IntakePanel } from '@/components/admin/pipeline/stage-panels/intake-panel'
|
|
||||||
import { FilterPanel } from '@/components/admin/pipeline/stage-panels/filter-panel'
|
|
||||||
import { EvaluationPanel } from '@/components/admin/pipeline/stage-panels/evaluation-panel'
|
|
||||||
import { SelectionPanel } from '@/components/admin/pipeline/stage-panels/selection-panel'
|
|
||||||
import { LiveFinalPanel } from '@/components/admin/pipeline/stage-panels/live-final-panel'
|
|
||||||
import { ResultsPanel } from '@/components/admin/pipeline/stage-panels/results-panel'
|
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
DRAFT: 'bg-gray-100 text-gray-700',
|
DRAFT: 'bg-gray-100 text-gray-700',
|
||||||
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||||
|
|
@ -62,39 +52,6 @@ const statusColors: Record<string, string> = {
|
||||||
CLOSED: 'bg-blue-100 text-blue-700',
|
CLOSED: 'bg-blue-100 text-blue-700',
|
||||||
}
|
}
|
||||||
|
|
||||||
function StagePanel({
|
|
||||||
stageId,
|
|
||||||
stageType,
|
|
||||||
configJson,
|
|
||||||
}: {
|
|
||||||
stageId: string
|
|
||||||
stageType: string
|
|
||||||
configJson: Record<string, unknown> | null
|
|
||||||
}) {
|
|
||||||
switch (stageType) {
|
|
||||||
case 'INTAKE':
|
|
||||||
return <IntakePanel stageId={stageId} configJson={configJson} />
|
|
||||||
case 'FILTER':
|
|
||||||
return <FilterPanel stageId={stageId} configJson={configJson} />
|
|
||||||
case 'EVALUATION':
|
|
||||||
return <EvaluationPanel stageId={stageId} configJson={configJson} />
|
|
||||||
case 'SELECTION':
|
|
||||||
return <SelectionPanel stageId={stageId} configJson={configJson} />
|
|
||||||
case 'LIVE_FINAL':
|
|
||||||
return <LiveFinalPanel stageId={stageId} configJson={configJson} />
|
|
||||||
case 'RESULTS':
|
|
||||||
return <ResultsPanel stageId={stageId} configJson={configJson} />
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
Unknown stage type: {stageType}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toWizardTrackConfig(
|
function toWizardTrackConfig(
|
||||||
track: {
|
track: {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -167,6 +124,7 @@ export default function PipelineDetailPage() {
|
||||||
|
|
||||||
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
|
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
|
||||||
const [selectedStageId, setSelectedStageId] = useState<string | null>(null)
|
const [selectedStageId, setSelectedStageId] = useState<string | null>(null)
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [structureTracks, setStructureTracks] = useState<WizardTrackConfig[]>([])
|
const [structureTracks, setStructureTracks] = useState<WizardTrackConfig[]>([])
|
||||||
const [notificationConfig, setNotificationConfig] = useState<Record<string, boolean>>({})
|
const [notificationConfig, setNotificationConfig] = useState<Record<string, boolean>>({})
|
||||||
const [overridePolicy, setOverridePolicy] = useState<Record<string, unknown>>({
|
const [overridePolicy, setOverridePolicy] = useState<Record<string, unknown>>({
|
||||||
|
|
@ -175,8 +133,6 @@ export default function PipelineDetailPage() {
|
||||||
const [structureDirty, setStructureDirty] = useState(false)
|
const [structureDirty, setStructureDirty] = useState(false)
|
||||||
const [settingsDirty, setSettingsDirty] = useState(false)
|
const [settingsDirty, setSettingsDirty] = useState(false)
|
||||||
|
|
||||||
const stagePanelRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
|
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
|
||||||
id: pipelineId,
|
id: pipelineId,
|
||||||
})
|
})
|
||||||
|
|
@ -220,15 +176,11 @@ export default function PipelineDetailPage() {
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-select first track and stage on load
|
// Auto-select first track on load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) {
|
if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) {
|
||||||
const firstTrack = pipeline.tracks.sort((a, b) => a.sortOrder - b.sortOrder)[0]
|
const firstTrack = pipeline.tracks.sort((a, b) => a.sortOrder - b.sortOrder)[0]
|
||||||
setSelectedTrackId(firstTrack.id)
|
setSelectedTrackId(firstTrack.id)
|
||||||
if (firstTrack.stages.length > 0) {
|
|
||||||
const firstStage = firstTrack.stages.sort((a, b) => a.sortOrder - b.sortOrder)[0]
|
|
||||||
setSelectedStageId(firstStage.id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [pipeline, selectedTrackId])
|
}, [pipeline, selectedTrackId])
|
||||||
|
|
||||||
|
|
@ -280,13 +232,6 @@ export default function PipelineDetailPage() {
|
||||||
setSettingsDirty(false)
|
setSettingsDirty(false)
|
||||||
}, [pipeline])
|
}, [pipeline])
|
||||||
|
|
||||||
// Scroll to stage panel when a stage is selected
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedStageId && stagePanelRef.current) {
|
|
||||||
stagePanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
||||||
}
|
|
||||||
}, [selectedStageId])
|
|
||||||
|
|
||||||
const trackOptionsForEditors = useMemo(
|
const trackOptionsForEditors = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(pipeline?.tracks ?? [])
|
(pipeline?.tracks ?? [])
|
||||||
|
|
@ -348,20 +293,17 @@ export default function PipelineDetailPage() {
|
||||||
(s) => s.id === selectedStageId
|
(s) => s.id === selectedStageId
|
||||||
)
|
)
|
||||||
const mainTrackDraft = structureTracks.find((track) => track.kind === 'MAIN')
|
const mainTrackDraft = structureTracks.find((track) => track.kind === 'MAIN')
|
||||||
|
const hasAwardTracks = pipeline.tracks.some((t) => t.kind === 'AWARD')
|
||||||
|
const hasMultipleTracks = pipeline.tracks.length > 1
|
||||||
|
|
||||||
const handleTrackChange = (trackId: string) => {
|
const handleTrackChange = (trackId: string) => {
|
||||||
setSelectedTrackId(trackId)
|
setSelectedTrackId(trackId)
|
||||||
const track = pipeline.tracks.find((t) => t.id === trackId)
|
setSelectedStageId(null)
|
||||||
if (track && track.stages.length > 0) {
|
|
||||||
const firstStage = track.stages.sort((a, b) => a.sortOrder - b.sortOrder)[0]
|
|
||||||
setSelectedStageId(firstStage.id)
|
|
||||||
} else {
|
|
||||||
setSelectedStageId(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStageSelect = (stageId: string) => {
|
const handleStageSelect = (stageId: string) => {
|
||||||
setSelectedStageId(stageId)
|
setSelectedStageId(stageId)
|
||||||
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStatusChange = async (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => {
|
const handleStatusChange = async (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => {
|
||||||
|
|
@ -426,7 +368,7 @@ export default function PipelineDetailPage() {
|
||||||
const flowchartTracks = selectedTrack ? [selectedTrack] : []
|
const flowchartTracks = selectedTrack ? [selectedTrack] : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
|
@ -593,7 +535,7 @@ export default function PipelineDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Track Switcher (only if multiple tracks) */}
|
{/* Track Switcher (only if multiple tracks) */}
|
||||||
{pipeline.tracks.length > 1 && (
|
{hasMultipleTracks && (
|
||||||
<div className="flex items-center gap-2 flex-wrap overflow-x-auto pb-1">
|
<div className="flex items-center gap-2 flex-wrap overflow-x-auto pb-1">
|
||||||
{pipeline.tracks
|
{pipeline.tracks
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
|
@ -627,11 +569,16 @@ export default function PipelineDetailPage() {
|
||||||
|
|
||||||
{/* Pipeline Flowchart */}
|
{/* Pipeline Flowchart */}
|
||||||
{flowchartTracks.length > 0 ? (
|
{flowchartTracks.length > 0 ? (
|
||||||
<PipelineFlowchart
|
<div>
|
||||||
tracks={flowchartTracks}
|
<PipelineFlowchart
|
||||||
selectedStageId={selectedStageId}
|
tracks={flowchartTracks}
|
||||||
onStageSelect={handleStageSelect}
|
selectedStageId={selectedStageId}
|
||||||
/>
|
onStageSelect={handleStageSelect}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Click a stage to edit its configuration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
|
@ -640,186 +587,160 @@ export default function PipelineDetailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Selected Stage Detail */}
|
{/* Stage Detail Sheet */}
|
||||||
<div ref={stagePanelRef}>
|
<StageDetailSheet
|
||||||
{selectedStage ? (
|
open={sheetOpen}
|
||||||
<div className="space-y-4">
|
onOpenChange={setSheetOpen}
|
||||||
<div className="border-t pt-4">
|
stage={
|
||||||
<h2 className="text-lg font-semibold text-muted-foreground">
|
selectedStage
|
||||||
Selected Stage: <span className="text-foreground">{selectedStage.name}</span>
|
? {
|
||||||
</h2>
|
id: selectedStage.id,
|
||||||
|
name: selectedStage.name,
|
||||||
|
stageType: selectedStage.stageType,
|
||||||
|
configJson: selectedStage.configJson as Record<string, unknown> | null,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onSaveConfig={updateStageConfig}
|
||||||
|
isSaving={isUpdating}
|
||||||
|
pipelineId={pipelineId}
|
||||||
|
materializeRequirements={(stageId) =>
|
||||||
|
materializeRequirementsMutation.mutate({ stageId })
|
||||||
|
}
|
||||||
|
isMaterializing={materializeRequirementsMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stage Management */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Stage Management</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Add, remove, reorder, or change stage types. Click a stage in the flowchart to edit its settings.
|
||||||
|
</p>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold">Pipeline Structure</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveStructure}
|
||||||
|
disabled={!structureDirty || updateStructureMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateStructureMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Save Structure
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stage Config Editor */}
|
{mainTrackDraft ? (
|
||||||
<StageConfigEditor
|
<MainTrackSection
|
||||||
stageId={selectedStage.id}
|
stages={mainTrackDraft.stages}
|
||||||
stageName={selectedStage.name}
|
onChange={updateMainTrackStages}
|
||||||
stageType={selectedStage.stageType}
|
|
||||||
configJson={selectedStage.configJson as Record<string, unknown> | null}
|
|
||||||
onSave={updateStageConfig}
|
|
||||||
isSaving={isUpdating}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedStage.stageType === 'INTAKE' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<h3 className="text-sm font-medium">Intake File Requirements</h3>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
materializeRequirementsMutation.mutate({
|
|
||||||
stageId: selectedStage.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={materializeRequirementsMutation.isPending}
|
|
||||||
>
|
|
||||||
{materializeRequirementsMutation.isPending && (
|
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
||||||
)}
|
|
||||||
Import Legacy Requirements
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<FileRequirementsEditor stageId={selectedStage.id} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedStage.stageType === 'FILTER' && (
|
|
||||||
<FilteringRulesEditor stageId={selectedStage.id} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stage Activity Panel */}
|
|
||||||
<StagePanel
|
|
||||||
stageId={selectedStage.id}
|
|
||||||
stageType={selectedStage.stageType}
|
|
||||||
configJson={selectedStage.configJson as Record<string, unknown> | null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedTrack && (
|
|
||||||
<StageTransitionsEditor
|
|
||||||
trackId={selectedTrack.id}
|
|
||||||
stages={selectedTrack.stages.map((stage) => ({
|
|
||||||
id: stage.id,
|
|
||||||
name: stage.name,
|
|
||||||
sortOrder: stage.sortOrder,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
)}
|
) : (
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-12 text-center">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Click a stage in the flowchart above to view its configuration and activity
|
No main track configured.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
|
||||||
)}
|
<AwardsSection
|
||||||
|
tracks={structureTracks}
|
||||||
|
onChange={(tracks) => {
|
||||||
|
setStructureTracks(tracks)
|
||||||
|
setStructureDirty(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RoutingRulesEditor
|
{/* Routing Rules (only if multiple tracks) */}
|
||||||
pipelineId={pipelineId}
|
{hasMultipleTracks && (
|
||||||
tracks={trackOptionsForEditors}
|
<div>
|
||||||
/>
|
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Routing Rules</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Define conditions for routing projects between tracks.
|
||||||
|
</p>
|
||||||
|
<RoutingRulesEditor
|
||||||
|
pipelineId={pipelineId}
|
||||||
|
tracks={trackOptionsForEditors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<AwardGovernanceEditor
|
{/* Award Governance (only if award tracks exist) */}
|
||||||
pipelineId={pipelineId}
|
{hasAwardTracks && (
|
||||||
tracks={pipeline.tracks
|
<div>
|
||||||
.filter((track) => track.kind === 'AWARD')
|
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Award Governance</h2>
|
||||||
.map((track) => ({
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
id: track.id,
|
Configure special awards, voting, and scoring for award tracks.
|
||||||
name: track.name,
|
</p>
|
||||||
decisionMode: track.decisionMode,
|
<AwardGovernanceEditor
|
||||||
specialAward: track.specialAward
|
pipelineId={pipelineId}
|
||||||
? {
|
tracks={pipeline.tracks
|
||||||
id: track.specialAward.id,
|
.filter((track) => track.kind === 'AWARD')
|
||||||
name: track.specialAward.name,
|
.map((track) => ({
|
||||||
description: track.specialAward.description,
|
id: track.id,
|
||||||
criteriaText: track.specialAward.criteriaText,
|
name: track.name,
|
||||||
useAiEligibility: track.specialAward.useAiEligibility,
|
decisionMode: track.decisionMode,
|
||||||
scoringMode: track.specialAward.scoringMode,
|
specialAward: track.specialAward
|
||||||
maxRankedPicks: track.specialAward.maxRankedPicks,
|
? {
|
||||||
votingStartAt: track.specialAward.votingStartAt,
|
id: track.specialAward.id,
|
||||||
votingEndAt: track.specialAward.votingEndAt,
|
name: track.specialAward.name,
|
||||||
status: track.specialAward.status,
|
description: track.specialAward.description,
|
||||||
}
|
criteriaText: track.specialAward.criteriaText,
|
||||||
: null,
|
useAiEligibility: track.specialAward.useAiEligibility,
|
||||||
}))}
|
scoringMode: track.specialAward.scoringMode,
|
||||||
/>
|
maxRankedPicks: track.specialAward.maxRankedPicks,
|
||||||
|
votingStartAt: track.specialAward.votingStartAt,
|
||||||
|
votingEndAt: track.specialAward.votingEndAt,
|
||||||
|
status: track.specialAward.status,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
{/* Settings */}
|
||||||
<CardContent className="pt-4 space-y-6">
|
<div>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Settings</h2>
|
||||||
<h2 className="text-sm font-semibold">Pipeline Configuration</h2>
|
<Card>
|
||||||
<Button
|
<CardContent className="pt-4 space-y-4">
|
||||||
type="button"
|
<div className="flex items-center justify-between gap-2">
|
||||||
size="sm"
|
<h3 className="text-sm font-semibold">Notifications and Overrides</h3>
|
||||||
onClick={handleSaveStructure}
|
<Button
|
||||||
disabled={!structureDirty || updateStructureMutation.isPending}
|
type="button"
|
||||||
>
|
size="sm"
|
||||||
{updateStructureMutation.isPending ? (
|
onClick={handleSaveSettings}
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
disabled={!settingsDirty || isUpdating}
|
||||||
) : (
|
>
|
||||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
{isUpdating ? (
|
||||||
)}
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
Save Structure
|
) : (
|
||||||
</Button>
|
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||||
</div>
|
)}
|
||||||
|
Save Settings
|
||||||
{mainTrackDraft ? (
|
</Button>
|
||||||
<MainTrackSection
|
</div>
|
||||||
stages={mainTrackDraft.stages}
|
<NotificationsSection
|
||||||
onChange={updateMainTrackStages}
|
config={notificationConfig}
|
||||||
|
onChange={(next) => {
|
||||||
|
setNotificationConfig(next)
|
||||||
|
setSettingsDirty(true)
|
||||||
|
}}
|
||||||
|
overridePolicy={overridePolicy}
|
||||||
|
onOverridePolicyChange={(next) => {
|
||||||
|
setOverridePolicy(next)
|
||||||
|
setSettingsDirty(true)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
</CardContent>
|
||||||
<p className="text-sm text-muted-foreground">
|
</Card>
|
||||||
No main track configured.
|
</div>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AwardsSection
|
|
||||||
tracks={structureTracks}
|
|
||||||
onChange={(tracks) => {
|
|
||||||
setStructureTracks(tracks)
|
|
||||||
setStructureDirty(true)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-4 space-y-4">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<h2 className="text-sm font-semibold">Notifications and Overrides</h2>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSaveSettings}
|
|
||||||
disabled={!settingsDirty || isUpdating}
|
|
||||||
>
|
|
||||||
{isUpdating ? (
|
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
Save Settings
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<NotificationsSection
|
|
||||||
config={notificationConfig}
|
|
||||||
onChange={(next) => {
|
|
||||||
setNotificationConfig(next)
|
|
||||||
setSettingsDirty(true)
|
|
||||||
}}
|
|
||||||
overridePolicy={overridePolicy}
|
|
||||||
onOverridePolicyChange={(next) => {
|
|
||||||
setOverridePolicy(next)
|
|
||||||
setSettingsDirty(true)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Code } from 'lucide-react'
|
||||||
|
|
||||||
|
const FIELD_OPTIONS = [
|
||||||
|
{ value: 'competitionCategory', label: 'Competition Category' },
|
||||||
|
{ value: 'oceanIssue', label: 'Ocean Issue' },
|
||||||
|
{ value: 'country', label: 'Country' },
|
||||||
|
{ value: 'geographicZone', label: 'Geographic Zone' },
|
||||||
|
{ value: 'wantsMentorship', label: 'Wants Mentorship' },
|
||||||
|
{ value: 'tags', label: 'Tags' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const OPERATOR_OPTIONS = [
|
||||||
|
{ value: 'equals', label: 'equals' },
|
||||||
|
{ value: 'not_equals', label: 'not equals' },
|
||||||
|
{ value: 'contains', label: 'contains' },
|
||||||
|
{ value: 'in', label: 'in' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type SimplePredicate = {
|
||||||
|
field: string
|
||||||
|
operator: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PredicateBuilderProps = {
|
||||||
|
value: Record<string, unknown>
|
||||||
|
onChange: (predicate: Record<string, unknown>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSimplePredicate(obj: Record<string, unknown>): obj is SimplePredicate {
|
||||||
|
return (
|
||||||
|
typeof obj.field === 'string' &&
|
||||||
|
typeof obj.operator === 'string' &&
|
||||||
|
(typeof obj.value === 'string' || typeof obj.value === 'boolean')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCompound(obj: Record<string, unknown>): boolean {
|
||||||
|
return 'or' in obj || 'and' in obj || 'not' in obj
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PredicateBuilder({ value, onChange }: PredicateBuilderProps) {
|
||||||
|
const [jsonMode, setJsonMode] = useState(false)
|
||||||
|
const [jsonText, setJsonText] = useState('')
|
||||||
|
|
||||||
|
const compound = isCompound(value)
|
||||||
|
const simple = !compound && isSimplePredicate(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (compound) {
|
||||||
|
setJsonMode(true)
|
||||||
|
setJsonText(JSON.stringify(value, null, 2))
|
||||||
|
}
|
||||||
|
}, [compound, value])
|
||||||
|
|
||||||
|
if (jsonMode) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs">Predicate (JSON)</Label>
|
||||||
|
{compound && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
Complex condition
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!compound && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonText) as Record<string, unknown>
|
||||||
|
onChange(parsed)
|
||||||
|
setJsonMode(false)
|
||||||
|
} catch {
|
||||||
|
// stay in JSON mode
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Switch to form
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
className="font-mono text-xs min-h-24"
|
||||||
|
value={jsonText}
|
||||||
|
onChange={(e) => {
|
||||||
|
setJsonText(e.target.value)
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(e.target.value) as Record<string, unknown>
|
||||||
|
onChange(parsed)
|
||||||
|
} catch {
|
||||||
|
// don't update on invalid JSON
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const predicate: SimplePredicate = simple
|
||||||
|
? { field: value.field as string, operator: value.operator as string, value: String(value.value) }
|
||||||
|
: { field: 'competitionCategory', operator: 'equals', value: '' }
|
||||||
|
|
||||||
|
const updateField = (field: string, val: string) => {
|
||||||
|
const next = { ...predicate, [field]: val }
|
||||||
|
onChange(next as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs">Condition</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs gap-1"
|
||||||
|
onClick={() => {
|
||||||
|
setJsonText(JSON.stringify(value, null, 2))
|
||||||
|
setJsonMode(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Code className="h-3 w-3" />
|
||||||
|
Edit as JSON
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">Field</Label>
|
||||||
|
<Select
|
||||||
|
value={predicate.field}
|
||||||
|
onValueChange={(v) => updateField('field', v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FIELD_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">Operator</Label>
|
||||||
|
<Select
|
||||||
|
value={predicate.operator}
|
||||||
|
onValueChange={(v) => updateField('operator', v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{OPERATOR_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">Value</Label>
|
||||||
|
<Input
|
||||||
|
className="h-8 text-xs"
|
||||||
|
value={predicate.value}
|
||||||
|
onChange={(e) => updateField('value', e.target.value)}
|
||||||
|
placeholder="e.g. STARTUP"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { PredicateBuilder } from '@/components/admin/pipeline/predicate-builder'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -52,7 +52,7 @@ type RuleDraft = {
|
||||||
destinationStageId: string | null
|
destinationStageId: string | null
|
||||||
priority: number
|
priority: number
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
predicateText: string
|
predicateJson: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PREDICATE = {
|
const DEFAULT_PREDICATE = {
|
||||||
|
|
@ -119,7 +119,7 @@ export function RoutingRulesEditor({
|
||||||
destinationStageId: rule.destinationStageId ?? null,
|
destinationStageId: rule.destinationStageId ?? null,
|
||||||
priority: rule.priority,
|
priority: rule.priority,
|
||||||
isActive: rule.isActive,
|
isActive: rule.isActive,
|
||||||
predicateText: JSON.stringify(rule.predicateJson ?? {}, null, 2),
|
predicateJson: (rule.predicateJson as Record<string, unknown>) ?? {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setDrafts(nextDrafts)
|
setDrafts(nextDrafts)
|
||||||
|
|
@ -148,14 +148,6 @@ export function RoutingRulesEditor({
|
||||||
const draft = drafts[id]
|
const draft = drafts[id]
|
||||||
if (!draft) return
|
if (!draft) return
|
||||||
|
|
||||||
let predicateJson: Record<string, unknown>
|
|
||||||
try {
|
|
||||||
predicateJson = JSON.parse(draft.predicateText) as Record<string, unknown>
|
|
||||||
} catch {
|
|
||||||
toast.error('Predicate must be valid JSON')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await upsertRule.mutateAsync({
|
await upsertRule.mutateAsync({
|
||||||
id: draft.id,
|
id: draft.id,
|
||||||
pipelineId,
|
pipelineId,
|
||||||
|
|
@ -166,7 +158,7 @@ export function RoutingRulesEditor({
|
||||||
destinationStageId: draft.destinationStageId,
|
destinationStageId: draft.destinationStageId,
|
||||||
priority: draft.priority,
|
priority: draft.priority,
|
||||||
isActive: draft.isActive,
|
isActive: draft.isActive,
|
||||||
predicateJson,
|
predicateJson: draft.predicateJson,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -370,19 +362,15 @@ export function RoutingRulesEditor({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<PredicateBuilder
|
||||||
<Label className="text-xs">Predicate (JSON)</Label>
|
value={draft.predicateJson}
|
||||||
<Textarea
|
onChange={(predicate) =>
|
||||||
className="font-mono text-xs min-h-24"
|
setDrafts((prev) => ({
|
||||||
value={draft.predicateText}
|
...prev,
|
||||||
onChange={(e) =>
|
[rule.id]: { ...draft, predicateJson: predicate },
|
||||||
setDrafts((prev) => ({
|
}))
|
||||||
...prev,
|
}
|
||||||
[rule.id]: { ...draft, predicateText: e.target.value },
|
/>
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
import { useState, useCallback, useEffect } from 'react'
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
import { EditableCard } from '@/components/ui/editable-card'
|
import { EditableCard } from '@/components/ui/editable-card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Inbox,
|
Inbox,
|
||||||
|
|
@ -45,6 +47,7 @@ type StageConfigEditorProps = {
|
||||||
configJson: Record<string, unknown> | null
|
configJson: Record<string, unknown> | null
|
||||||
onSave: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
|
onSave: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
|
||||||
isSaving?: boolean
|
isSaving?: boolean
|
||||||
|
alwaysEditable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const stageIcons: Record<string, React.ReactNode> = {
|
const stageIcons: Record<string, React.ReactNode> = {
|
||||||
|
|
@ -255,6 +258,7 @@ export function StageConfigEditor({
|
||||||
configJson,
|
configJson,
|
||||||
onSave,
|
onSave,
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
|
alwaysEditable = false,
|
||||||
}: StageConfigEditorProps) {
|
}: StageConfigEditorProps) {
|
||||||
const [localConfig, setLocalConfig] = useState<Record<string, unknown>>(
|
const [localConfig, setLocalConfig] = useState<Record<string, unknown>>(
|
||||||
() => configJson ?? {}
|
() => configJson ?? {}
|
||||||
|
|
@ -375,6 +379,29 @@ export function StageConfigEditor({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (alwaysEditable) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{stageIcons[stageType] && (
|
||||||
|
<span className="text-muted-foreground">{stageIcons[stageType]}</span>
|
||||||
|
)}
|
||||||
|
<h3 className="text-sm font-semibold">{stageName} Configuration</h3>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{stageType.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{renderEditor()}
|
||||||
|
<div className="flex justify-end pt-2 border-t">
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
||||||
|
{isSaving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditableCard
|
<EditableCard
|
||||||
title={`${stageName} Configuration`}
|
title={`${stageName} Configuration`}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { StageConfigEditor } from '@/components/admin/pipeline/stage-config-editor'
|
||||||
|
import { FileRequirementsEditor } from '@/components/admin/file-requirements-editor'
|
||||||
|
import { FilteringRulesEditor } from '@/components/admin/pipeline/filtering-rules-editor'
|
||||||
|
|
||||||
|
import { IntakePanel } from '@/components/admin/pipeline/stage-panels/intake-panel'
|
||||||
|
import { FilterPanel } from '@/components/admin/pipeline/stage-panels/filter-panel'
|
||||||
|
import { EvaluationPanel } from '@/components/admin/pipeline/stage-panels/evaluation-panel'
|
||||||
|
import { SelectionPanel } from '@/components/admin/pipeline/stage-panels/selection-panel'
|
||||||
|
import { LiveFinalPanel } from '@/components/admin/pipeline/stage-panels/live-final-panel'
|
||||||
|
import { ResultsPanel } from '@/components/admin/pipeline/stage-panels/results-panel'
|
||||||
|
|
||||||
|
type StageType = 'INTAKE' | 'FILTER' | 'EVALUATION' | 'SELECTION' | 'LIVE_FINAL' | 'RESULTS'
|
||||||
|
|
||||||
|
type StageDetailSheetProps = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
stage: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
stageType: StageType
|
||||||
|
configJson: Record<string, unknown> | null
|
||||||
|
} | null
|
||||||
|
onSaveConfig: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
|
||||||
|
isSaving: boolean
|
||||||
|
pipelineId: string
|
||||||
|
materializeRequirements?: (stageId: string) => void
|
||||||
|
isMaterializing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function StagePanelContent({
|
||||||
|
stageId,
|
||||||
|
stageType,
|
||||||
|
configJson,
|
||||||
|
}: {
|
||||||
|
stageId: string
|
||||||
|
stageType: string
|
||||||
|
configJson: Record<string, unknown> | null
|
||||||
|
}) {
|
||||||
|
switch (stageType) {
|
||||||
|
case 'INTAKE':
|
||||||
|
return <IntakePanel stageId={stageId} configJson={configJson} />
|
||||||
|
case 'FILTER':
|
||||||
|
return <FilterPanel stageId={stageId} configJson={configJson} />
|
||||||
|
case 'EVALUATION':
|
||||||
|
return <EvaluationPanel stageId={stageId} configJson={configJson} />
|
||||||
|
case 'SELECTION':
|
||||||
|
return <SelectionPanel stageId={stageId} configJson={configJson} />
|
||||||
|
case 'LIVE_FINAL':
|
||||||
|
return <LiveFinalPanel stageId={stageId} configJson={configJson} />
|
||||||
|
case 'RESULTS':
|
||||||
|
return <ResultsPanel stageId={stageId} configJson={configJson} />
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground py-4">
|
||||||
|
Unknown stage type: {stageType}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stageTypeLabels: Record<string, string> = {
|
||||||
|
INTAKE: 'Intake',
|
||||||
|
FILTER: 'Filter',
|
||||||
|
EVALUATION: 'Evaluation',
|
||||||
|
SELECTION: 'Selection',
|
||||||
|
LIVE_FINAL: 'Live Final',
|
||||||
|
RESULTS: 'Results',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StageDetailSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
stage,
|
||||||
|
onSaveConfig,
|
||||||
|
isSaving,
|
||||||
|
pipelineId: _pipelineId,
|
||||||
|
materializeRequirements,
|
||||||
|
isMaterializing = false,
|
||||||
|
}: StageDetailSheetProps) {
|
||||||
|
if (!stage) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
className="w-full sm:w-[540px] lg:w-[640px] sm:max-w-[640px] overflow-y-auto p-0"
|
||||||
|
>
|
||||||
|
<div className="p-6 pb-0">
|
||||||
|
<SheetHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SheetTitle className="text-base">{stage.name}</SheetTitle>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{stageTypeLabels[stage.stageType] ?? stage.stageType}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<SheetDescription>
|
||||||
|
Configure settings and view activity for this stage
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 pt-4 pb-6">
|
||||||
|
<Tabs defaultValue="configuration">
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="configuration" className="flex-1">
|
||||||
|
Configuration
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="activity" className="flex-1">
|
||||||
|
Activity
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="configuration" className="space-y-4 mt-4">
|
||||||
|
<StageConfigEditor
|
||||||
|
stageId={stage.id}
|
||||||
|
stageName={stage.name}
|
||||||
|
stageType={stage.stageType}
|
||||||
|
configJson={stage.configJson}
|
||||||
|
onSave={onSaveConfig}
|
||||||
|
isSaving={isSaving}
|
||||||
|
alwaysEditable
|
||||||
|
/>
|
||||||
|
|
||||||
|
{stage.stageType === 'INTAKE' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-medium">Intake File Requirements</h3>
|
||||||
|
{materializeRequirements && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => materializeRequirements(stage.id)}
|
||||||
|
disabled={isMaterializing}
|
||||||
|
>
|
||||||
|
{isMaterializing && (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
)}
|
||||||
|
Import Legacy Requirements
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FileRequirementsEditor stageId={stage.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.stageType === 'FILTER' && (
|
||||||
|
<FilteringRulesEditor stageId={stage.id} />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="activity" className="mt-4">
|
||||||
|
<StagePanelContent
|
||||||
|
stageId={stage.id}
|
||||||
|
stageType={stage.stageType}
|
||||||
|
configJson={stage.configJson}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Sheet = SheetPrimitive.Root
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger
|
||||||
|
const SheetClose = SheetPrimitive.Close
|
||||||
|
const SheetPortal = SheetPrimitive.Portal
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||||
|
bottom:
|
||||||
|
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||||
|
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||||
|
right:
|
||||||
|
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: 'right',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type SheetContentProps = React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> &
|
||||||
|
VariantProps<typeof sheetVariants>
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(({ side = 'right', className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(sheetVariants({ side }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
{children}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
))
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SheetHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col space-y-2 text-center sm:text-left',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
SheetHeader.displayName = 'SheetHeader'
|
||||||
|
|
||||||
|
const SheetFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
SheetFooter.displayName = 'SheetFooter'
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold text-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetPortal,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue