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'
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
|
@ -34,27 +34,17 @@ import {
|
|||
|
||||
import { InlineEditableText } from '@/components/ui/inline-editable-text'
|
||||
import { PipelineFlowchart } from '@/components/admin/pipeline/pipeline-flowchart'
|
||||
import { StageConfigEditor } from '@/components/admin/pipeline/stage-config-editor'
|
||||
import { StageDetailSheet } from '@/components/admin/pipeline/stage-detail-sheet'
|
||||
import { usePipelineInlineEdit } from '@/hooks/use-pipeline-inline-edit'
|
||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
||||
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-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 { StageTransitionsEditor } from '@/components/admin/pipeline/stage-transitions-editor'
|
||||
import { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor'
|
||||
import { normalizeStageConfig } from '@/lib/stage-config-schema'
|
||||
import { defaultNotificationConfig } from '@/lib/pipeline-defaults'
|
||||
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> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-700',
|
||||
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||
|
|
@ -62,39 +52,6 @@ const statusColors: Record<string, string> = {
|
|||
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(
|
||||
track: {
|
||||
id: string
|
||||
|
|
@ -167,6 +124,7 @@ export default function PipelineDetailPage() {
|
|||
|
||||
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
|
||||
const [selectedStageId, setSelectedStageId] = useState<string | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [structureTracks, setStructureTracks] = useState<WizardTrackConfig[]>([])
|
||||
const [notificationConfig, setNotificationConfig] = useState<Record<string, boolean>>({})
|
||||
const [overridePolicy, setOverridePolicy] = useState<Record<string, unknown>>({
|
||||
|
|
@ -175,8 +133,6 @@ export default function PipelineDetailPage() {
|
|||
const [structureDirty, setStructureDirty] = useState(false)
|
||||
const [settingsDirty, setSettingsDirty] = useState(false)
|
||||
|
||||
const stagePanelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
|
||||
id: pipelineId,
|
||||
})
|
||||
|
|
@ -220,15 +176,11 @@ export default function PipelineDetailPage() {
|
|||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Auto-select first track and stage on load
|
||||
// Auto-select first track on load
|
||||
useEffect(() => {
|
||||
if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) {
|
||||
const firstTrack = pipeline.tracks.sort((a, b) => a.sortOrder - b.sortOrder)[0]
|
||||
setSelectedTrackId(firstTrack.id)
|
||||
if (firstTrack.stages.length > 0) {
|
||||
const firstStage = firstTrack.stages.sort((a, b) => a.sortOrder - b.sortOrder)[0]
|
||||
setSelectedStageId(firstStage.id)
|
||||
}
|
||||
}
|
||||
}, [pipeline, selectedTrackId])
|
||||
|
||||
|
|
@ -280,13 +232,6 @@ export default function PipelineDetailPage() {
|
|||
setSettingsDirty(false)
|
||||
}, [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(
|
||||
() =>
|
||||
(pipeline?.tracks ?? [])
|
||||
|
|
@ -348,20 +293,17 @@ export default function PipelineDetailPage() {
|
|||
(s) => s.id === selectedStageId
|
||||
)
|
||||
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) => {
|
||||
setSelectedTrackId(trackId)
|
||||
const track = pipeline.tracks.find((t) => t.id === trackId)
|
||||
if (track && track.stages.length > 0) {
|
||||
const firstStage = track.stages.sort((a, b) => a.sortOrder - b.sortOrder)[0]
|
||||
setSelectedStageId(firstStage.id)
|
||||
} else {
|
||||
setSelectedStageId(null)
|
||||
}
|
||||
setSelectedStageId(null)
|
||||
}
|
||||
|
||||
const handleStageSelect = (stageId: string) => {
|
||||
setSelectedStageId(stageId)
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
const handleStatusChange = async (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => {
|
||||
|
|
@ -426,7 +368,7 @@ export default function PipelineDetailPage() {
|
|||
const flowchartTracks = selectedTrack ? [selectedTrack] : []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
|
|
@ -593,7 +535,7 @@ export default function PipelineDetailPage() {
|
|||
</div>
|
||||
|
||||
{/* 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">
|
||||
{pipeline.tracks
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
|
|
@ -627,11 +569,16 @@ export default function PipelineDetailPage() {
|
|||
|
||||
{/* Pipeline Flowchart */}
|
||||
{flowchartTracks.length > 0 ? (
|
||||
<PipelineFlowchart
|
||||
tracks={flowchartTracks}
|
||||
selectedStageId={selectedStageId}
|
||||
onStageSelect={handleStageSelect}
|
||||
/>
|
||||
<div>
|
||||
<PipelineFlowchart
|
||||
tracks={flowchartTracks}
|
||||
selectedStageId={selectedStageId}
|
||||
onStageSelect={handleStageSelect}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Click a stage to edit its configuration
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
|
|
@ -640,186 +587,160 @@ export default function PipelineDetailPage() {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Selected Stage Detail */}
|
||||
<div ref={stagePanelRef}>
|
||||
{selectedStage ? (
|
||||
<div className="space-y-4">
|
||||
<div className="border-t pt-4">
|
||||
<h2 className="text-lg font-semibold text-muted-foreground">
|
||||
Selected Stage: <span className="text-foreground">{selectedStage.name}</span>
|
||||
</h2>
|
||||
{/* Stage Detail Sheet */}
|
||||
<StageDetailSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
stage={
|
||||
selectedStage
|
||||
? {
|
||||
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>
|
||||
|
||||
{/* Stage Config Editor */}
|
||||
<StageConfigEditor
|
||||
stageId={selectedStage.id}
|
||||
stageName={selectedStage.name}
|
||||
stageType={selectedStage.stageType}
|
||||
configJson={selectedStage.configJson as Record<string, unknown> | null}
|
||||
onSave={updateStageConfig}
|
||||
isSaving={isUpdating}
|
||||
/>
|
||||
|
||||
{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,
|
||||
}))}
|
||||
{mainTrackDraft ? (
|
||||
<MainTrackSection
|
||||
stages={mainTrackDraft.stages}
|
||||
onChange={updateMainTrackStages}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click a stage in the flowchart above to view its configuration and activity
|
||||
No main track configured.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
)}
|
||||
|
||||
<AwardsSection
|
||||
tracks={structureTracks}
|
||||
onChange={(tracks) => {
|
||||
setStructureTracks(tracks)
|
||||
setStructureDirty(true)
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<RoutingRulesEditor
|
||||
pipelineId={pipelineId}
|
||||
tracks={trackOptionsForEditors}
|
||||
/>
|
||||
{/* 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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AwardGovernanceEditor
|
||||
pipelineId={pipelineId}
|
||||
tracks={pipeline.tracks
|
||||
.filter((track) => track.kind === 'AWARD')
|
||||
.map((track) => ({
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
decisionMode: track.decisionMode,
|
||||
specialAward: track.specialAward
|
||||
? {
|
||||
id: track.specialAward.id,
|
||||
name: track.specialAward.name,
|
||||
description: track.specialAward.description,
|
||||
criteriaText: track.specialAward.criteriaText,
|
||||
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,
|
||||
}))}
|
||||
/>
|
||||
{/* Award Governance (only if award tracks exist) */}
|
||||
{hasAwardTracks && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Award Governance</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Configure special awards, voting, and scoring for award tracks.
|
||||
</p>
|
||||
<AwardGovernanceEditor
|
||||
pipelineId={pipelineId}
|
||||
tracks={pipeline.tracks
|
||||
.filter((track) => track.kind === 'AWARD')
|
||||
.map((track) => ({
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
decisionMode: track.decisionMode,
|
||||
specialAward: track.specialAward
|
||||
? {
|
||||
id: track.specialAward.id,
|
||||
name: track.specialAward.name,
|
||||
description: track.specialAward.description,
|
||||
criteriaText: track.specialAward.criteriaText,
|
||||
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>
|
||||
<CardContent className="pt-4 space-y-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold">Pipeline Configuration</h2>
|
||||
<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>
|
||||
|
||||
{mainTrackDraft ? (
|
||||
<MainTrackSection
|
||||
stages={mainTrackDraft.stages}
|
||||
onChange={updateMainTrackStages}
|
||||
{/* Settings */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Settings</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">Notifications and Overrides</h3>
|
||||
<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)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No main track configured.
|
||||
</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>
|
||||
</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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { PredicateBuilder } from '@/components/admin/pipeline/predicate-builder'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -52,7 +52,7 @@ type RuleDraft = {
|
|||
destinationStageId: string | null
|
||||
priority: number
|
||||
isActive: boolean
|
||||
predicateText: string
|
||||
predicateJson: Record<string, unknown>
|
||||
}
|
||||
|
||||
const DEFAULT_PREDICATE = {
|
||||
|
|
@ -119,7 +119,7 @@ export function RoutingRulesEditor({
|
|||
destinationStageId: rule.destinationStageId ?? null,
|
||||
priority: rule.priority,
|
||||
isActive: rule.isActive,
|
||||
predicateText: JSON.stringify(rule.predicateJson ?? {}, null, 2),
|
||||
predicateJson: (rule.predicateJson as Record<string, unknown>) ?? {},
|
||||
}
|
||||
}
|
||||
setDrafts(nextDrafts)
|
||||
|
|
@ -148,14 +148,6 @@ export function RoutingRulesEditor({
|
|||
const draft = drafts[id]
|
||||
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({
|
||||
id: draft.id,
|
||||
pipelineId,
|
||||
|
|
@ -166,7 +158,7 @@ export function RoutingRulesEditor({
|
|||
destinationStageId: draft.destinationStageId,
|
||||
priority: draft.priority,
|
||||
isActive: draft.isActive,
|
||||
predicateJson,
|
||||
predicateJson: draft.predicateJson,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -370,19 +362,15 @@ export function RoutingRulesEditor({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Predicate (JSON)</Label>
|
||||
<Textarea
|
||||
className="font-mono text-xs min-h-24"
|
||||
value={draft.predicateText}
|
||||
onChange={(e) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[rule.id]: { ...draft, predicateText: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<PredicateBuilder
|
||||
value={draft.predicateJson}
|
||||
onChange={(predicate) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[rule.id]: { ...draft, predicateJson: predicate },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { EditableCard } from '@/components/ui/editable-card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Inbox,
|
||||
|
|
@ -45,6 +47,7 @@ type StageConfigEditorProps = {
|
|||
configJson: Record<string, unknown> | null
|
||||
onSave: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
|
||||
isSaving?: boolean
|
||||
alwaysEditable?: boolean
|
||||
}
|
||||
|
||||
const stageIcons: Record<string, React.ReactNode> = {
|
||||
|
|
@ -255,6 +258,7 @@ export function StageConfigEditor({
|
|||
configJson,
|
||||
onSave,
|
||||
isSaving = false,
|
||||
alwaysEditable = false,
|
||||
}: StageConfigEditorProps) {
|
||||
const [localConfig, setLocalConfig] = useState<Record<string, unknown>>(
|
||||
() => 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 (
|
||||
<EditableCard
|
||||
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