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
File diff suppressed because it is too large
Load Diff
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
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,
|
||||||
|
|
@ -13,30 +15,30 @@ import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
||||||
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
|
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
|
||||||
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
|
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
|
||||||
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
|
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
|
||||||
import { SelectionSection } from '@/components/admin/pipeline/sections/selection-section'
|
import { SelectionSection } from '@/components/admin/pipeline/sections/selection-section'
|
||||||
import { ResultsSection } from '@/components/admin/pipeline/sections/results-section'
|
import { ResultsSection } from '@/components/admin/pipeline/sections/results-section'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
defaultIntakeConfig,
|
defaultIntakeConfig,
|
||||||
defaultFilterConfig,
|
defaultFilterConfig,
|
||||||
defaultEvaluationConfig,
|
defaultEvaluationConfig,
|
||||||
defaultLiveConfig,
|
defaultLiveConfig,
|
||||||
defaultSelectionConfig,
|
defaultSelectionConfig,
|
||||||
defaultResultsConfig,
|
defaultResultsConfig,
|
||||||
} from '@/lib/pipeline-defaults'
|
} from '@/lib/pipeline-defaults'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
IntakeConfig,
|
IntakeConfig,
|
||||||
FilterConfig,
|
FilterConfig,
|
||||||
EvaluationConfig,
|
EvaluationConfig,
|
||||||
LiveFinalConfig,
|
LiveFinalConfig,
|
||||||
SelectionConfig,
|
SelectionConfig,
|
||||||
ResultsConfig,
|
ResultsConfig,
|
||||||
} from '@/types/pipeline-wizard'
|
} from '@/types/pipeline-wizard'
|
||||||
|
|
||||||
type StageConfigEditorProps = {
|
type StageConfigEditorProps = {
|
||||||
stageId: string
|
stageId: string
|
||||||
|
|
@ -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> = {
|
||||||
|
|
@ -248,21 +251,22 @@ function ConfigSummary({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StageConfigEditor({
|
export function StageConfigEditor({
|
||||||
stageId,
|
stageId,
|
||||||
stageName,
|
stageName,
|
||||||
stageType,
|
stageType,
|
||||||
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 ?? {}
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalConfig(configJson ?? {})
|
setLocalConfig(configJson ?? {})
|
||||||
}, [stageId, configJson])
|
}, [stageId, configJson])
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
await onSave(stageId, localConfig)
|
await onSave(stageId, localConfig)
|
||||||
|
|
@ -350,29 +354,52 @@ export function StageConfigEditor({
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case 'SELECTION':
|
case 'SELECTION':
|
||||||
return (
|
return (
|
||||||
<SelectionSection
|
<SelectionSection
|
||||||
config={{
|
config={{
|
||||||
...defaultSelectionConfig(),
|
...defaultSelectionConfig(),
|
||||||
...(localConfig as SelectionConfig),
|
...(localConfig as SelectionConfig),
|
||||||
}}
|
}}
|
||||||
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case 'RESULTS':
|
case 'RESULTS':
|
||||||
return (
|
return (
|
||||||
<ResultsSection
|
<ResultsSection
|
||||||
config={{
|
config={{
|
||||||
...defaultResultsConfig(),
|
...defaultResultsConfig(),
|
||||||
...(localConfig as ResultsConfig),
|
...(localConfig as ResultsConfig),
|
||||||
}}
|
}}
|
||||||
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
|
|
||||||
|
|
@ -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