Compare commits
No commits in common. "382570cebd178b73868f41b1d82532610ea43fde" and "c321d4711e001df8841aa5909b11b887ad72aa24" have entirely different histories.
382570cebd
...
c321d4711e
|
|
@ -20,7 +20,6 @@ import {
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { Route } from 'next'
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
|
@ -31,7 +30,6 @@ import {
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Save,
|
Save,
|
||||||
Wand2,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { InlineEditableText } from '@/components/ui/inline-editable-text'
|
import { InlineEditableText } from '@/components/ui/inline-editable-text'
|
||||||
|
|
@ -43,8 +41,8 @@ import { AwardsSection } from '@/components/admin/pipeline/sections/awards-secti
|
||||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
||||||
import { RoutingRulesEditor } from '@/components/admin/pipeline/routing-rules-editor'
|
import { RoutingRulesEditor } from '@/components/admin/pipeline/routing-rules-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 { defaultNotificationConfig } from '@/lib/pipeline-defaults'
|
import { defaultNotificationConfig } from '@/lib/pipeline-defaults'
|
||||||
import { toWizardTrackConfig } from '@/lib/pipeline-conversions'
|
|
||||||
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
|
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
|
|
@ -54,6 +52,71 @@ const statusColors: Record<string, string> = {
|
||||||
CLOSED: 'bg-blue-100 text-blue-700',
|
CLOSED: 'bg-blue-100 text-blue-700',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toWizardTrackConfig(
|
||||||
|
track: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
kind: 'MAIN' | 'AWARD' | 'SHOWCASE'
|
||||||
|
sortOrder: number
|
||||||
|
routingMode: 'PARALLEL' | 'EXCLUSIVE' | 'POST_MAIN' | null
|
||||||
|
decisionMode:
|
||||||
|
| 'JURY_VOTE'
|
||||||
|
| 'AWARD_MASTER_DECISION'
|
||||||
|
| 'ADMIN_DECISION'
|
||||||
|
| null
|
||||||
|
stages: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
stageType:
|
||||||
|
| 'INTAKE'
|
||||||
|
| 'FILTER'
|
||||||
|
| 'EVALUATION'
|
||||||
|
| 'SELECTION'
|
||||||
|
| 'LIVE_FINAL'
|
||||||
|
| 'RESULTS'
|
||||||
|
sortOrder: number
|
||||||
|
configJson: unknown
|
||||||
|
}>
|
||||||
|
specialAward?: {
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
): WizardTrackConfig {
|
||||||
|
return {
|
||||||
|
id: track.id,
|
||||||
|
name: track.name,
|
||||||
|
slug: track.slug,
|
||||||
|
kind: track.kind,
|
||||||
|
sortOrder: track.sortOrder,
|
||||||
|
routingModeDefault: track.routingMode ?? undefined,
|
||||||
|
decisionMode: track.decisionMode ?? undefined,
|
||||||
|
stages: track.stages
|
||||||
|
.map((stage) => ({
|
||||||
|
id: stage.id,
|
||||||
|
name: stage.name,
|
||||||
|
slug: stage.slug,
|
||||||
|
stageType: stage.stageType,
|
||||||
|
sortOrder: stage.sortOrder,
|
||||||
|
configJson: normalizeStageConfig(
|
||||||
|
stage.stageType,
|
||||||
|
stage.configJson as Record<string, unknown> | null
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder),
|
||||||
|
awardConfig: track.specialAward
|
||||||
|
? {
|
||||||
|
name: track.specialAward.name,
|
||||||
|
description: track.specialAward.description ?? undefined,
|
||||||
|
scoringMode: track.specialAward.scoringMode,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function PipelineDetailPage() {
|
export default function PipelineDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const pipelineId = params.id as string
|
const pipelineId = params.id as string
|
||||||
|
|
@ -387,13 +450,6 @@ export default function PipelineDetailPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/admin/rounds/pipeline/${pipelineId}/wizard` as Route}>
|
|
||||||
<Wand2 className="h-4 w-4 mr-2" />
|
|
||||||
Edit in Wizard
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{pipeline.status === 'DRAFT' && (
|
{pipeline.status === 'DRAFT' && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={publishMutation.isPending}
|
disabled={publishMutation.isPending}
|
||||||
|
|
@ -604,6 +660,10 @@ export default function PipelineDetailPage() {
|
||||||
{/* Routing Rules (only if multiple tracks) */}
|
{/* Routing Rules (only if multiple tracks) */}
|
||||||
{hasMultipleTracks && (
|
{hasMultipleTracks && (
|
||||||
<div>
|
<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
|
<RoutingRulesEditor
|
||||||
pipelineId={pipelineId}
|
pipelineId={pipelineId}
|
||||||
tracks={trackOptionsForEditors}
|
tracks={trackOptionsForEditors}
|
||||||
|
|
|
||||||
|
|
@ -1,410 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
||||||
import { useRouter, useParams } from 'next/navigation'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
|
|
||||||
import type { StepConfig } from '@/components/ui/sidebar-stepper'
|
|
||||||
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
|
|
||||||
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
|
||||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
|
||||||
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
|
|
||||||
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
|
|
||||||
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
|
||||||
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
|
|
||||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
|
||||||
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
|
|
||||||
|
|
||||||
import { defaultNotificationConfig, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
|
|
||||||
import { toWizardTrackConfig } from '@/lib/pipeline-conversions'
|
|
||||||
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
|
|
||||||
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
|
|
||||||
|
|
||||||
export default function EditPipelineWizardPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const params = useParams()
|
|
||||||
const pipelineId = params.id as string
|
|
||||||
|
|
||||||
const [state, setState] = useState<WizardState | null>(null)
|
|
||||||
const [currentStep, setCurrentStep] = useState(0)
|
|
||||||
const initialStateRef = useRef<string>('')
|
|
||||||
|
|
||||||
// Load existing pipeline data
|
|
||||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery(
|
|
||||||
{ id: pipelineId },
|
|
||||||
{ enabled: !!pipelineId }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Initialize state when pipeline data loads
|
|
||||||
useEffect(() => {
|
|
||||||
if (pipeline && !state) {
|
|
||||||
const settings = (pipeline.settingsJson as Record<string, unknown> | null) ?? {}
|
|
||||||
const initialState: WizardState = {
|
|
||||||
name: pipeline.name,
|
|
||||||
slug: pipeline.slug,
|
|
||||||
programId: pipeline.programId,
|
|
||||||
settingsJson: settings,
|
|
||||||
tracks: pipeline.tracks
|
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
|
||||||
.map(track => toWizardTrackConfig({
|
|
||||||
id: track.id,
|
|
||||||
name: track.name,
|
|
||||||
slug: track.slug,
|
|
||||||
kind: track.kind as 'MAIN' | 'AWARD' | 'SHOWCASE',
|
|
||||||
sortOrder: track.sortOrder,
|
|
||||||
routingMode: track.routingMode as 'PARALLEL' | 'EXCLUSIVE' | 'POST_MAIN' | null,
|
|
||||||
decisionMode: track.decisionMode as 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION' | null,
|
|
||||||
stages: track.stages.map(s => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
slug: s.slug,
|
|
||||||
stageType: s.stageType as 'INTAKE' | 'FILTER' | 'EVALUATION' | 'SELECTION' | 'LIVE_FINAL' | 'RESULTS',
|
|
||||||
sortOrder: s.sortOrder,
|
|
||||||
configJson: s.configJson,
|
|
||||||
})),
|
|
||||||
specialAward: track.specialAward ? {
|
|
||||||
name: track.specialAward.name,
|
|
||||||
description: track.specialAward.description,
|
|
||||||
scoringMode: track.specialAward.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED',
|
|
||||||
} : null,
|
|
||||||
})),
|
|
||||||
notificationConfig: (settings.notificationConfig as Record<string, boolean>) ?? defaultNotificationConfig(),
|
|
||||||
overridePolicy: (settings.overridePolicy as Record<string, unknown>) ?? { allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
|
||||||
}
|
|
||||||
setState(initialState)
|
|
||||||
initialStateRef.current = JSON.stringify(initialState)
|
|
||||||
}
|
|
||||||
}, [pipeline, state])
|
|
||||||
|
|
||||||
// Dirty tracking — warn on navigate away
|
|
||||||
useEffect(() => {
|
|
||||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
||||||
if (state && JSON.stringify(state) !== initialStateRef.current) {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
}, [state])
|
|
||||||
|
|
||||||
const updateState = useCallback((updates: Partial<WizardState>) => {
|
|
||||||
setState((prev) => prev ? { ...prev, ...updates } : prev)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Mutations
|
|
||||||
const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateSettingsMutation = trpc.pipeline.update.useMutation({
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const publishMutation = trpc.pipeline.publish.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Pipeline published successfully')
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSave = async (publish: boolean) => {
|
|
||||||
if (!state) return
|
|
||||||
|
|
||||||
const validation = validateAll(state)
|
|
||||||
if (!validation.valid) {
|
|
||||||
toast.error('Please fix validation errors before saving')
|
|
||||||
if (!validation.sections.basics.valid) setCurrentStep(0)
|
|
||||||
else if (!validation.sections.tracks.valid) setCurrentStep(2)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateStructureMutation.mutateAsync({
|
|
||||||
id: pipelineId,
|
|
||||||
name: state.name,
|
|
||||||
slug: state.slug,
|
|
||||||
settingsJson: {
|
|
||||||
...state.settingsJson,
|
|
||||||
notificationConfig: state.notificationConfig,
|
|
||||||
overridePolicy: state.overridePolicy,
|
|
||||||
},
|
|
||||||
tracks: state.tracks.map((t) => ({
|
|
||||||
id: t.id,
|
|
||||||
name: t.name,
|
|
||||||
slug: t.slug,
|
|
||||||
kind: t.kind,
|
|
||||||
sortOrder: t.sortOrder,
|
|
||||||
routingModeDefault: t.routingModeDefault,
|
|
||||||
decisionMode: t.decisionMode,
|
|
||||||
stages: t.stages.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
slug: s.slug,
|
|
||||||
stageType: s.stageType,
|
|
||||||
sortOrder: s.sortOrder,
|
|
||||||
configJson: s.configJson,
|
|
||||||
})),
|
|
||||||
awardConfig: t.awardConfig,
|
|
||||||
})),
|
|
||||||
autoTransitions: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
await updateSettingsMutation.mutateAsync({
|
|
||||||
id: pipelineId,
|
|
||||||
settingsJson: {
|
|
||||||
...state.settingsJson,
|
|
||||||
notificationConfig: state.notificationConfig,
|
|
||||||
overridePolicy: state.overridePolicy,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (publish) {
|
|
||||||
await publishMutation.mutateAsync({ id: pipelineId })
|
|
||||||
}
|
|
||||||
|
|
||||||
initialStateRef.current = JSON.stringify(state)
|
|
||||||
toast.success(publish ? 'Pipeline saved and published' : 'Pipeline changes saved')
|
|
||||||
router.push(`/admin/rounds/pipeline/${pipelineId}` as Route)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSaving = updateStructureMutation.isPending && !publishMutation.isPending
|
|
||||||
const isSubmitting = publishMutation.isPending
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (isLoading || !state) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href={`/admin/rounds/pipeline/${pipelineId}` as Route}>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Edit Pipeline (Wizard)</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">Loading pipeline data...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center py-20">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get stage configs from the main track
|
|
||||||
const mainTrack = state.tracks.find((t) => t.kind === 'MAIN')
|
|
||||||
const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE')
|
|
||||||
const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER')
|
|
||||||
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
|
|
||||||
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
|
|
||||||
|
|
||||||
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
|
|
||||||
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
|
|
||||||
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
|
|
||||||
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
|
|
||||||
|
|
||||||
const updateStageConfig = (stageType: string, configJson: Record<string, unknown>) => {
|
|
||||||
setState((prev) => {
|
|
||||||
if (!prev) return prev
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
tracks: prev.tracks.map((track) => {
|
|
||||||
if (track.kind !== 'MAIN') return track
|
|
||||||
return {
|
|
||||||
...track,
|
|
||||||
stages: track.stages.map((stage) =>
|
|
||||||
stage.stageType === stageType ? { ...stage, configJson } : stage
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateMainTrackStages = (stages: WizardState['tracks'][0]['stages']) => {
|
|
||||||
setState((prev) => {
|
|
||||||
if (!prev) return prev
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
tracks: prev.tracks.map((track) =>
|
|
||||||
track.kind === 'MAIN' ? { ...track, stages } : track
|
|
||||||
),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
const basicsValid = validateBasics(state).valid
|
|
||||||
const tracksValid = validateTracks(state.tracks).valid
|
|
||||||
const allValid = validateAll(state).valid
|
|
||||||
|
|
||||||
// Step configuration
|
|
||||||
const steps: StepConfig[] = [
|
|
||||||
{
|
|
||||||
title: 'Basics',
|
|
||||||
description: 'Pipeline name and program',
|
|
||||||
isValid: basicsValid,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Intake',
|
|
||||||
description: 'Submission window & files',
|
|
||||||
isValid: !!intakeStage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Main Track Stages',
|
|
||||||
description: 'Configure pipeline stages',
|
|
||||||
isValid: tracksValid,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Screening',
|
|
||||||
description: 'Gate rules and AI screening',
|
|
||||||
isValid: !!filterStage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Evaluation',
|
|
||||||
description: 'Jury assignment strategy',
|
|
||||||
isValid: !!evalStage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Awards',
|
|
||||||
description: 'Special award tracks',
|
|
||||||
isValid: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Live Finals',
|
|
||||||
description: 'Voting and reveal settings',
|
|
||||||
isValid: !!liveStage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Notifications',
|
|
||||||
description: 'Event notifications',
|
|
||||||
isValid: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Review & Save',
|
|
||||||
description: 'Validation summary',
|
|
||||||
isValid: allValid,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 pb-8">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href={`/admin/rounds/pipeline/${pipelineId}` as Route}>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Edit Pipeline (Wizard)</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Modify the pipeline structure for project evaluation
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar Stepper */}
|
|
||||||
<SidebarStepper
|
|
||||||
steps={steps}
|
|
||||||
currentStep={currentStep}
|
|
||||||
onStepChange={setCurrentStep}
|
|
||||||
onSave={() => handleSave(false)}
|
|
||||||
onSubmit={() => handleSave(true)}
|
|
||||||
isSaving={isSaving}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
saveLabel="Save Changes"
|
|
||||||
submitLabel="Save & Publish"
|
|
||||||
canSubmit={allValid}
|
|
||||||
>
|
|
||||||
{/* Step 0: Basics */}
|
|
||||||
<div>
|
|
||||||
<BasicsSection state={state} onChange={updateState} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 1: Intake */}
|
|
||||||
<div>
|
|
||||||
<IntakeSection
|
|
||||||
config={intakeConfig}
|
|
||||||
onChange={(c) =>
|
|
||||||
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 2: Main Track Stages */}
|
|
||||||
<div>
|
|
||||||
<MainTrackSection
|
|
||||||
stages={mainTrack?.stages ?? []}
|
|
||||||
onChange={updateMainTrackStages}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 3: Screening */}
|
|
||||||
<div>
|
|
||||||
<FilteringSection
|
|
||||||
config={filterConfig}
|
|
||||||
onChange={(c) =>
|
|
||||||
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 4: Evaluation */}
|
|
||||||
<div>
|
|
||||||
<AssignmentSection
|
|
||||||
config={evalConfig}
|
|
||||||
onChange={(c) =>
|
|
||||||
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 5: Awards */}
|
|
||||||
<div>
|
|
||||||
<AwardsSection
|
|
||||||
tracks={state.tracks}
|
|
||||||
onChange={(tracks) => updateState({ tracks })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 6: Live Finals */}
|
|
||||||
<div>
|
|
||||||
<LiveFinalsSection
|
|
||||||
config={liveConfig}
|
|
||||||
onChange={(c) =>
|
|
||||||
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 7: Notifications */}
|
|
||||||
<div>
|
|
||||||
<NotificationsSection
|
|
||||||
config={state.notificationConfig}
|
|
||||||
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
|
||||||
overridePolicy={state.overridePolicy}
|
|
||||||
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 8: Review & Save */}
|
|
||||||
<div>
|
|
||||||
<ReviewSection state={state} />
|
|
||||||
</div>
|
|
||||||
</SidebarStepper>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -15,8 +15,10 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Layers,
|
Layers,
|
||||||
|
GitBranch,
|
||||||
Calendar,
|
Calendar,
|
||||||
Workflow,
|
Workflow,
|
||||||
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
|
@ -147,14 +149,18 @@ export default function PipelineListPage() {
|
||||||
const description = (pipeline.settingsJson as Record<string, unknown> | null)?.description as string | undefined
|
const description = (pipeline.settingsJson as Record<string, unknown> | null)?.description as string | undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={pipeline.id} href={`/admin/rounds/pipeline/${pipeline.id}` as Route}>
|
<Card key={pipeline.id} className="group hover:shadow-md transition-shadow h-full flex flex-col">
|
||||||
<Card className="group cursor-pointer hover:shadow-md transition-shadow h-full flex flex-col">
|
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<CardTitle className="text-base leading-tight">
|
<CardTitle className="text-base leading-tight mb-1">
|
||||||
|
<Link href={`/admin/rounds/pipeline/${pipeline.id}` as Route} className="hover:underline">
|
||||||
{pipeline.name}
|
{pipeline.name}
|
||||||
|
</Link>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground truncate">
|
||||||
|
{pipeline.slug}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
@ -168,6 +174,7 @@ export default function PipelineListPage() {
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-2">
|
<p className="text-xs text-muted-foreground line-clamp-2 mt-2">
|
||||||
{description}
|
{description}
|
||||||
|
|
@ -176,8 +183,9 @@ export default function PipelineListPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="mt-auto">
|
<CardContent className="mt-auto">
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
{/* Track Indicator - Simplified visualization */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="mb-3 pb-3 border-b">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1.5">
|
||||||
<Layers className="h-3.5 w-3.5" />
|
<Layers className="h-3.5 w-3.5" />
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{pipeline._count.tracks === 0
|
{pipeline._count.tracks === 0
|
||||||
|
|
@ -187,11 +195,52 @@ export default function PipelineListPage() {
|
||||||
: `${pipeline._count.tracks} tracks`}
|
: `${pipeline._count.tracks} tracks`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{pipeline._count.tracks > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: Math.min(pipeline._count.tracks, 5) }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-6 flex-1 rounded border border-border bg-muted/30 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div className="h-1 w-1 rounded-full bg-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{pipeline._count.tracks > 5 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-1">
|
||||||
|
+{pipeline._count.tracks - 5}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
|
<span>Routing rules</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{pipeline._count.routingRules}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>Updated {formatDistanceToNow(new Date(pipeline.updatedAt))} ago</span>
|
<span>Updated {formatDistanceToNow(new Date(pipeline.updatedAt))} ago</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route} className="w-full">
|
||||||
|
<Button size="sm" variant="outline" className="w-full">
|
||||||
|
<Pencil className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -171,8 +171,9 @@ export function MembersContent() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Users on the current page that are selectable (status NONE)
|
||||||
const selectableUsers = useMemo(
|
const selectableUsers = useMemo(
|
||||||
() => data?.users ?? [],
|
() => (data?.users ?? []).filter((u) => u.status === 'NONE'),
|
||||||
[data?.users]
|
[data?.users]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -322,7 +323,7 @@ export function MembersContent() {
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allSelectableSelected ? true : someSelectableSelected ? 'indeterminate' : false}
|
checked={allSelectableSelected ? true : someSelectableSelected ? 'indeterminate' : false}
|
||||||
onCheckedChange={toggleAll}
|
onCheckedChange={toggleAll}
|
||||||
aria-label="Select all members"
|
aria-label="Select all uninvited members"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
|
@ -339,11 +340,15 @@ export function MembersContent() {
|
||||||
{data.users.map((user) => (
|
{data.users.map((user) => (
|
||||||
<TableRow key={user.id}>
|
<TableRow key={user.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
{user.status === 'NONE' ? (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedIds.has(user.id)}
|
checked={selectedIds.has(user.id)}
|
||||||
onCheckedChange={() => toggleUser(user.id)}
|
onCheckedChange={() => toggleUser(user.id)}
|
||||||
aria-label={`Select ${user.name || user.email}`}
|
aria-label={`Select ${user.name || user.email}`}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -433,12 +438,14 @@ export function MembersContent() {
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{user.status === 'NONE' && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedIds.has(user.id)}
|
checked={selectedIds.has(user.id)}
|
||||||
onCheckedChange={() => toggleUser(user.id)}
|
onCheckedChange={() => toggleUser(user.id)}
|
||||||
aria-label={`Select ${user.name || user.email}`}
|
aria-label={`Select ${user.name || user.email}`}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
user={user}
|
user={user}
|
||||||
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
|
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Input } from '@/components/ui/input'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -12,189 +13,143 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import {
|
import { Code } from 'lucide-react'
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip'
|
|
||||||
import { Plus, X, Loader2, Sparkles, AlertCircle } from 'lucide-react'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
// ─── Field & Operator Definitions ────────────────────────────────────────────
|
|
||||||
|
|
||||||
const FIELD_OPTIONS = [
|
const FIELD_OPTIONS = [
|
||||||
{ value: 'competitionCategory', label: 'Competition Category', tooltip: 'Values: STARTUP, BUSINESS_CONCEPT' },
|
{ value: 'competitionCategory', label: 'Competition Category' },
|
||||||
{ value: 'oceanIssue', label: 'Ocean Issue', tooltip: 'The ocean issue the project addresses' },
|
{ value: 'oceanIssue', label: 'Ocean Issue' },
|
||||||
{ value: 'country', label: 'Country', tooltip: 'Country of origin' },
|
{ value: 'country', label: 'Country' },
|
||||||
{ value: 'geographicZone', label: 'Geographic Zone', tooltip: 'Geographic zone of the project' },
|
{ value: 'geographicZone', label: 'Geographic Zone' },
|
||||||
{ value: 'wantsMentorship', label: 'Wants Mentorship', tooltip: 'Boolean: true or false' },
|
{ value: 'wantsMentorship', label: 'Wants Mentorship' },
|
||||||
{ value: 'tags', label: 'Tags', tooltip: 'Project tags (comma-separated for "in" operator)' },
|
{ value: 'tags', label: 'Tags' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const OPERATOR_OPTIONS = [
|
const OPERATOR_OPTIONS = [
|
||||||
{ value: 'eq', label: 'equals' },
|
{ value: 'equals', label: 'equals' },
|
||||||
{ value: 'neq', label: 'does not equal' },
|
{ value: 'not_equals', label: 'not equals' },
|
||||||
{ value: 'in', label: 'is one of' },
|
|
||||||
{ value: 'contains', label: 'contains' },
|
{ value: 'contains', label: 'contains' },
|
||||||
{ value: 'gt', label: 'greater than' },
|
{ value: 'in', label: 'in' },
|
||||||
{ value: 'lt', label: 'less than' },
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
type SimplePredicate = {
|
||||||
|
|
||||||
type SimpleCondition = {
|
|
||||||
field: string
|
field: string
|
||||||
operator: string
|
operator: string
|
||||||
value: unknown
|
value: string
|
||||||
}
|
|
||||||
|
|
||||||
type CompoundPredicate = {
|
|
||||||
logic: 'and' | 'or'
|
|
||||||
conditions: SimpleCondition[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PredicateBuilderProps = {
|
type PredicateBuilderProps = {
|
||||||
value: Record<string, unknown>
|
value: Record<string, unknown>
|
||||||
onChange: (predicate: Record<string, unknown>) => void
|
onChange: (predicate: Record<string, unknown>) => void
|
||||||
pipelineId?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
function isSimplePredicate(obj: Record<string, unknown>): obj is SimplePredicate {
|
||||||
|
return (
|
||||||
function isSimpleCondition(obj: Record<string, unknown>): obj is SimpleCondition {
|
typeof obj.field === 'string' &&
|
||||||
return typeof obj.field === 'string' && typeof obj.operator === 'string' && 'value' in obj
|
typeof obj.operator === 'string' &&
|
||||||
}
|
(typeof obj.value === 'string' || typeof obj.value === 'boolean')
|
||||||
|
|
||||||
function isCompoundPredicate(obj: Record<string, unknown>): obj is CompoundPredicate {
|
|
||||||
return 'logic' in obj && Array.isArray((obj as CompoundPredicate).conditions)
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectInitialMode(value: Record<string, unknown>): 'simple' | 'ai' | 'advanced' {
|
|
||||||
if (isCompoundPredicate(value)) return 'simple'
|
|
||||||
if (isSimpleCondition(value)) return 'simple'
|
|
||||||
// Empty object or unknown shape
|
|
||||||
if (Object.keys(value).length === 0) return 'simple'
|
|
||||||
return 'advanced'
|
|
||||||
}
|
|
||||||
|
|
||||||
function valueToConditions(value: Record<string, unknown>): SimpleCondition[] {
|
|
||||||
if (isCompoundPredicate(value)) {
|
|
||||||
return value.conditions.map((c) => ({
|
|
||||||
field: c.field || 'competitionCategory',
|
|
||||||
operator: c.operator || 'eq',
|
|
||||||
value: c.value ?? '',
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
if (isSimpleCondition(value)) {
|
|
||||||
return [{ field: value.field, operator: value.operator, value: value.value }]
|
|
||||||
}
|
|
||||||
return [{ field: 'competitionCategory', operator: 'eq', value: '' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
function valueToLogic(value: Record<string, unknown>): 'and' | 'or' {
|
|
||||||
if (isCompoundPredicate(value)) return value.logic
|
|
||||||
return 'and'
|
|
||||||
}
|
|
||||||
|
|
||||||
function conditionsToPredicate(
|
|
||||||
conditions: SimpleCondition[],
|
|
||||||
logic: 'and' | 'or'
|
|
||||||
): Record<string, unknown> {
|
|
||||||
if (conditions.length === 1) {
|
|
||||||
return conditions[0] as unknown as Record<string, unknown>
|
|
||||||
}
|
|
||||||
return { logic, conditions }
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayValue(val: unknown): string {
|
|
||||||
if (Array.isArray(val)) return val.join(', ')
|
|
||||||
if (typeof val === 'boolean') return val ? 'true' : 'false'
|
|
||||||
return String(val ?? '')
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseInputValue(text: string, field: string): unknown {
|
|
||||||
if (field === 'wantsMentorship') {
|
|
||||||
return text.toLowerCase() === 'true'
|
|
||||||
}
|
|
||||||
if (text.includes(',')) {
|
|
||||||
return text.split(',').map((s) => s.trim()).filter(Boolean)
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Simple Mode ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function SimpleMode({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: Record<string, unknown>
|
|
||||||
onChange: (predicate: Record<string, unknown>) => void
|
|
||||||
}) {
|
|
||||||
const [conditions, setConditions] = useState<SimpleCondition[]>(() => valueToConditions(value))
|
|
||||||
const [logic, setLogic] = useState<'and' | 'or'>(() => valueToLogic(value))
|
|
||||||
|
|
||||||
const emitChange = useCallback(
|
|
||||||
(nextConditions: SimpleCondition[], nextLogic: 'and' | 'or') => {
|
|
||||||
onChange(conditionsToPredicate(nextConditions, nextLogic))
|
|
||||||
},
|
|
||||||
[onChange]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateCondition = (index: number, field: keyof SimpleCondition, val: unknown) => {
|
|
||||||
const next = conditions.map((c, i) => (i === index ? { ...c, [field]: val } : c))
|
|
||||||
setConditions(next)
|
|
||||||
emitChange(next, logic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const addCondition = () => {
|
function isCompound(obj: Record<string, unknown>): boolean {
|
||||||
const next = [...conditions, { field: 'competitionCategory', operator: 'eq', value: '' }]
|
return 'or' in obj || 'and' in obj || 'not' in obj
|
||||||
setConditions(next)
|
|
||||||
emitChange(next, logic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeCondition = (index: number) => {
|
export function PredicateBuilder({ value, onChange }: PredicateBuilderProps) {
|
||||||
if (conditions.length <= 1) return
|
const [jsonMode, setJsonMode] = useState(false)
|
||||||
const next = conditions.filter((_, i) => i !== index)
|
const [jsonText, setJsonText] = useState('')
|
||||||
setConditions(next)
|
|
||||||
emitChange(next, logic)
|
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 toggleLogic = () => {
|
const predicate: SimplePredicate = simple
|
||||||
const nextLogic = logic === 'and' ? 'or' : 'and'
|
? { field: value.field as string, operator: value.operator as string, value: String(value.value) }
|
||||||
setLogic(nextLogic)
|
: { field: 'competitionCategory', operator: 'equals', value: '' }
|
||||||
emitChange(conditions, nextLogic)
|
|
||||||
|
const updateField = (field: string, val: string) => {
|
||||||
|
const next = { ...predicate, [field]: val }
|
||||||
|
onChange(next as unknown as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={300}>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{conditions.map((condition, index) => (
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div key={index}>
|
<Label className="text-xs">Condition</Label>
|
||||||
{index > 0 && (
|
|
||||||
<div className="flex items-center gap-2 py-1">
|
|
||||||
<div className="h-px flex-1 bg-border" />
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-5 px-2 text-[10px] font-medium"
|
className="h-6 text-xs gap-1"
|
||||||
onClick={toggleLogic}
|
onClick={() => {
|
||||||
|
setJsonText(JSON.stringify(value, null, 2))
|
||||||
|
setJsonMode(true)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{logic.toUpperCase()}
|
<Code className="h-3 w-3" />
|
||||||
|
Edit as JSON
|
||||||
</Button>
|
</Button>
|
||||||
<div className="h-px flex-1 bg-border" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="space-y-1">
|
||||||
<Tooltip>
|
<Label className="text-[10px] text-muted-foreground">Field</Label>
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="w-[160px] shrink-0">
|
|
||||||
<Select
|
<Select
|
||||||
value={condition.field}
|
value={predicate.field}
|
||||||
onValueChange={(v) => updateCondition(index, 'field', v)}
|
onValueChange={(v) => updateField('field', v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -208,16 +163,11 @@ function SimpleMode({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
<div className="space-y-1">
|
||||||
<TooltipContent side="top">
|
<Label className="text-[10px] text-muted-foreground">Operator</Label>
|
||||||
{FIELD_OPTIONS.find((f) => f.value === condition.field)?.tooltip || 'Select a field'}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<div className="w-[130px] shrink-0">
|
|
||||||
<Select
|
<Select
|
||||||
value={condition.operator}
|
value={predicate.operator}
|
||||||
onValueChange={(v) => updateCondition(index, 'operator', v)}
|
onValueChange={(v) => updateField('operator', v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -231,240 +181,16 @@ function SimpleMode({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">Value</Label>
|
||||||
<Input
|
<Input
|
||||||
className="h-8 text-xs flex-1 min-w-[100px]"
|
className="h-8 text-xs"
|
||||||
value={displayValue(condition.value)}
|
value={predicate.value}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateField('value', e.target.value)}
|
||||||
updateCondition(index, 'value', parseInputValue(e.target.value, condition.field))
|
placeholder="e.g. STARTUP"
|
||||||
}
|
|
||||||
placeholder={condition.field === 'wantsMentorship' ? 'true / false' : 'e.g. STARTUP'}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() => removeCondition(index)}
|
|
||||||
disabled={conditions.length <= 1}
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs"
|
|
||||||
onClick={addCondition}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
Add condition
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── AI Mode ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function AIMode({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
pipelineId,
|
|
||||||
onSwitchToSimple,
|
|
||||||
}: {
|
|
||||||
value: Record<string, unknown>
|
|
||||||
onChange: (predicate: Record<string, unknown>) => void
|
|
||||||
pipelineId?: string
|
|
||||||
onSwitchToSimple: () => void
|
|
||||||
}) {
|
|
||||||
const [text, setText] = useState('')
|
|
||||||
const [result, setResult] = useState<{
|
|
||||||
predicateJson: Record<string, unknown>
|
|
||||||
explanation: string
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
const parseRule = trpc.routing.parseNaturalLanguageRule.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setResult(data)
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleGenerate = () => {
|
|
||||||
if (!text.trim()) return
|
|
||||||
if (!pipelineId) {
|
|
||||||
toast.error('Pipeline ID is required for AI parsing')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
parseRule.mutate({ text: text.trim(), pipelineId })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleApply = () => {
|
|
||||||
if (result) {
|
|
||||||
onChange(result.predicateJson)
|
|
||||||
toast.success('Rule applied')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Textarea
|
|
||||||
className="text-xs min-h-16"
|
|
||||||
placeholder='Describe your rule in plain English, e.g. "Route startup projects from France to the Fast Track"'
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
disabled={parseRule.isPending}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={!text.trim() || parseRule.isPending || !pipelineId}
|
|
||||||
>
|
|
||||||
{parseRule.isPending ? (
|
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
Generate Rule
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!pipelineId && (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<AlertCircle className="h-3.5 w-3.5" />
|
|
||||||
Save the pipeline first to enable AI rule generation.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && (
|
|
||||||
<div className="rounded-md border bg-muted/50 p-3 space-y-2">
|
|
||||||
<div className="text-xs font-medium">Generated Rule</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{result.explanation}</p>
|
|
||||||
<pre className="text-[10px] font-mono bg-background rounded p-2 overflow-x-auto">
|
|
||||||
{JSON.stringify(result.predicateJson, null, 2)}
|
|
||||||
</pre>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button type="button" size="sm" className="h-7 text-xs" onClick={handleApply}>
|
|
||||||
Apply Rule
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs"
|
|
||||||
onClick={() => {
|
|
||||||
onChange(result.predicateJson)
|
|
||||||
onSwitchToSimple()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit in Simple mode
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Object.keys(value).length > 0 && !result && (
|
|
||||||
<div className="text-[10px] text-muted-foreground">
|
|
||||||
Current predicate: <code className="font-mono">{JSON.stringify(value)}</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Advanced Mode ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function AdvancedMode({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: Record<string, unknown>
|
|
||||||
onChange: (predicate: Record<string, unknown>) => void
|
|
||||||
}) {
|
|
||||||
const [jsonText, setJsonText] = useState(() => JSON.stringify(value, null, 2))
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleChange = (text: string) => {
|
|
||||||
setJsonText(text)
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(text) as Record<string, unknown>
|
|
||||||
setError(null)
|
|
||||||
onChange(parsed)
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Invalid JSON')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Textarea
|
|
||||||
className="font-mono text-xs min-h-28"
|
|
||||||
value={jsonText}
|
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
|
||||||
placeholder='{ "field": "competitionCategory", "operator": "eq", "value": "STARTUP" }'
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-destructive">
|
|
||||||
<AlertCircle className="h-3 w-3" />
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
Use <code className="font-mono">{'{ field, operator, value }'}</code> for simple conditions
|
|
||||||
or <code className="font-mono">{'{ logic: "and"|"or", conditions: [...] }'}</code> for compound rules.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function PredicateBuilder({ value, onChange, pipelineId }: PredicateBuilderProps) {
|
|
||||||
const [activeTab, setActiveTab] = useState<string>(() => detectInitialMode(value))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
||||||
<TabsList className="h-8">
|
|
||||||
<TabsTrigger value="simple" className="text-xs px-3 h-6">
|
|
||||||
Simple
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="ai" className="text-xs px-3 h-6">
|
|
||||||
<Sparkles className="mr-1 h-3 w-3" />
|
|
||||||
AI
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="advanced" className="text-xs px-3 h-6">
|
|
||||||
Advanced
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="simple">
|
|
||||||
<SimpleMode value={value} onChange={onChange} />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="ai">
|
|
||||||
<AIMode
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
pipelineId={pipelineId}
|
|
||||||
onSwitchToSimple={() => setActiveTab('simple')}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="advanced">
|
|
||||||
<AdvancedMode value={value} onChange={onChange} />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { 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 { PredicateBuilder } from '@/components/admin/pipeline/predicate-builder'
|
import { PredicateBuilder } from '@/components/admin/pipeline/predicate-builder'
|
||||||
|
|
@ -14,33 +15,17 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip'
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible'
|
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
Loader2,
|
Loader2,
|
||||||
Power,
|
Power,
|
||||||
PowerOff,
|
PowerOff,
|
||||||
ChevronDown,
|
|
||||||
ArrowRight,
|
|
||||||
HelpCircle,
|
|
||||||
Settings2,
|
|
||||||
Route,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type StageLite = {
|
type StageLite = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -72,374 +57,16 @@ type RuleDraft = {
|
||||||
|
|
||||||
const DEFAULT_PREDICATE = {
|
const DEFAULT_PREDICATE = {
|
||||||
field: 'competitionCategory',
|
field: 'competitionCategory',
|
||||||
operator: 'eq',
|
operator: 'equals',
|
||||||
value: 'STARTUP',
|
value: 'STARTUP',
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Predicate Summarizer ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const FIELD_LABELS: Record<string, string> = {
|
|
||||||
competitionCategory: 'Competition Category',
|
|
||||||
oceanIssue: 'Ocean Issue',
|
|
||||||
country: 'Country',
|
|
||||||
geographicZone: 'Geographic Zone',
|
|
||||||
wantsMentorship: 'Wants Mentorship',
|
|
||||||
tags: 'Tags',
|
|
||||||
}
|
|
||||||
|
|
||||||
const OPERATOR_LABELS: Record<string, string> = {
|
|
||||||
eq: 'is',
|
|
||||||
neq: 'is not',
|
|
||||||
in: 'is one of',
|
|
||||||
contains: 'contains',
|
|
||||||
gt: '>',
|
|
||||||
lt: '<',
|
|
||||||
// Legacy operators
|
|
||||||
equals: 'is',
|
|
||||||
not_equals: 'is not',
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeValue(val: unknown): string {
|
|
||||||
if (Array.isArray(val)) return val.join(', ')
|
|
||||||
if (typeof val === 'boolean') return val ? 'Yes' : 'No'
|
|
||||||
return String(val ?? '')
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizePredicate(predicate: Record<string, unknown>): string {
|
|
||||||
// Simple condition
|
|
||||||
if (typeof predicate.field === 'string' && typeof predicate.operator === 'string') {
|
|
||||||
const field = FIELD_LABELS[predicate.field] || predicate.field
|
|
||||||
const op = OPERATOR_LABELS[predicate.operator as string] || predicate.operator
|
|
||||||
const val = summarizeValue(predicate.value)
|
|
||||||
return `${field} ${op} ${val}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compound condition
|
|
||||||
if (predicate.logic && Array.isArray(predicate.conditions)) {
|
|
||||||
const conditions = predicate.conditions as Array<Record<string, unknown>>
|
|
||||||
if (conditions.length === 0) return 'No conditions'
|
|
||||||
const parts = conditions.map((c) => {
|
|
||||||
if (typeof c.field === 'string' && typeof c.operator === 'string') {
|
|
||||||
const field = FIELD_LABELS[c.field] || c.field
|
|
||||||
const op = OPERATOR_LABELS[c.operator as string] || c.operator
|
|
||||||
const val = summarizeValue(c.value)
|
|
||||||
return `${field} ${op} ${val}`
|
|
||||||
}
|
|
||||||
return 'Custom condition'
|
|
||||||
})
|
|
||||||
const joiner = predicate.logic === 'or' ? ' or ' : ' and '
|
|
||||||
return parts.join(joiner)
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Custom condition'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Rule Card ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function RuleCard({
|
|
||||||
draft,
|
|
||||||
index,
|
|
||||||
tracks,
|
|
||||||
pipelineId,
|
|
||||||
expandedId,
|
|
||||||
onToggleExpand,
|
|
||||||
onUpdateDraft,
|
|
||||||
onSave,
|
|
||||||
onDelete,
|
|
||||||
onToggleActive,
|
|
||||||
isSaving,
|
|
||||||
isDeleting,
|
|
||||||
isToggling,
|
|
||||||
}: {
|
|
||||||
draft: RuleDraft
|
|
||||||
index: number
|
|
||||||
tracks: TrackLite[]
|
|
||||||
pipelineId: string
|
|
||||||
expandedId: string | null
|
|
||||||
onToggleExpand: (id: string) => void
|
|
||||||
onUpdateDraft: (id: string, updates: Partial<RuleDraft>) => void
|
|
||||||
onSave: (id: string) => void
|
|
||||||
onDelete: (id: string) => void
|
|
||||||
onToggleActive: (id: string, isActive: boolean) => void
|
|
||||||
isSaving: boolean
|
|
||||||
isDeleting: boolean
|
|
||||||
isToggling: boolean
|
|
||||||
}) {
|
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
|
||||||
const isExpanded = expandedId === draft.id
|
|
||||||
|
|
||||||
const destinationTrack = tracks.find((t) => t.id === draft.destinationTrackId)
|
|
||||||
const destinationTrackName = destinationTrack?.name || 'Unknown Track'
|
|
||||||
const conditionSummary = summarizePredicate(draft.predicateJson)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Collapsible open={isExpanded} onOpenChange={() => onToggleExpand(draft.id)}>
|
|
||||||
{/* Collapsed header */}
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full flex items-center gap-3 rounded-md border px-3 py-2.5 text-left hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
{/* Priority badge */}
|
|
||||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-semibold">
|
|
||||||
#{index + 1}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Active dot */}
|
|
||||||
<span
|
|
||||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
|
||||||
draft.isActive ? 'bg-green-500' : 'bg-gray-300'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
<span className="flex-1 text-xs truncate">
|
|
||||||
<span className="text-muted-foreground">Route projects where </span>
|
|
||||||
<span className="font-medium">{conditionSummary}</span>
|
|
||||||
<span className="text-muted-foreground"> → </span>
|
|
||||||
<span className="font-medium">{destinationTrackName}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Chevron */}
|
|
||||||
<ChevronDown
|
|
||||||
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
|
|
||||||
isExpanded ? 'rotate-180' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
{/* Expanded content */}
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="border border-t-0 rounded-b-md px-4 py-4 space-y-4 -mt-px">
|
|
||||||
{/* Rule name */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Rule Name</Label>
|
|
||||||
<Input
|
|
||||||
className="h-8 text-sm"
|
|
||||||
value={draft.name}
|
|
||||||
onChange={(e) => onUpdateDraft(draft.id, { name: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Track routing flow: Source → Destination Track → Destination Stage */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Route To</Label>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<div className="w-[180px]">
|
|
||||||
<Select
|
|
||||||
value={draft.sourceTrackId ?? '__none__'}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
onUpdateDraft(draft.id, {
|
|
||||||
sourceTrackId: value === '__none__' ? null : value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue placeholder="Source" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__none__">Any Track</SelectItem>
|
|
||||||
{tracks.map((track) => (
|
|
||||||
<SelectItem key={track.id} value={track.id}>
|
|
||||||
{track.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
||||||
|
|
||||||
<div className="w-[180px]">
|
|
||||||
<Select
|
|
||||||
value={draft.destinationTrackId}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const track = tracks.find((t) => t.id === value)
|
|
||||||
onUpdateDraft(draft.id, {
|
|
||||||
destinationTrackId: value,
|
|
||||||
destinationStageId: track?.stages[0]?.id ?? null,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue placeholder="Destination Track" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{tracks.map((track) => (
|
|
||||||
<SelectItem key={track.id} value={track.id}>
|
|
||||||
{track.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
||||||
|
|
||||||
<div className="w-[180px]">
|
|
||||||
<Select
|
|
||||||
value={draft.destinationStageId ?? '__none__'}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
onUpdateDraft(draft.id, {
|
|
||||||
destinationStageId: value === '__none__' ? null : value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue placeholder="Stage" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__none__">Track Start</SelectItem>
|
|
||||||
{(destinationTrack?.stages ?? [])
|
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
|
||||||
.map((stage) => (
|
|
||||||
<SelectItem key={stage.id} value={stage.id}>
|
|
||||||
{stage.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Predicate builder */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Conditions</Label>
|
|
||||||
<PredicateBuilder
|
|
||||||
value={draft.predicateJson}
|
|
||||||
onChange={(predicate) =>
|
|
||||||
onUpdateDraft(draft.id, { predicateJson: predicate })
|
|
||||||
}
|
|
||||||
pipelineId={pipelineId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Advanced settings (collapsible) */}
|
|
||||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs gap-1.5 text-muted-foreground"
|
|
||||||
>
|
|
||||||
<Settings2 className="h-3 w-3" />
|
|
||||||
Advanced Settings
|
|
||||||
<ChevronDown
|
|
||||||
className={`h-3 w-3 transition-transform ${showAdvanced ? 'rotate-180' : ''}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="mt-2 grid gap-3 sm:grid-cols-2 rounded-md border p-3 bg-muted/30">
|
|
||||||
<TooltipProvider delayDuration={300}>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Label className="text-xs">Scope</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<HelpCircle className="h-3 w-3 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="max-w-[200px]">
|
|
||||||
Global: applies to all projects. Track/Stage: only applies to projects in a specific track or stage.
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={draft.scope}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
onUpdateDraft(draft.id, { scope: value as RuleDraft['scope'] })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="global">Global</SelectItem>
|
|
||||||
<SelectItem value="track">Track</SelectItem>
|
|
||||||
<SelectItem value="stage">Stage</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Priority</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
value={draft.priority}
|
|
||||||
onChange={(e) =>
|
|
||||||
onUpdateDraft(draft.id, {
|
|
||||||
priority: parseInt(e.target.value, 10) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
{/* Action bar */}
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-1 border-t">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
onClick={() => onToggleActive(draft.id, !draft.isActive)}
|
|
||||||
disabled={isToggling}
|
|
||||||
>
|
|
||||||
{draft.isActive ? (
|
|
||||||
<PowerOff className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<Power className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
{draft.isActive ? 'Disable' : 'Enable'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 text-xs text-destructive hover:text-destructive"
|
|
||||||
onClick={() => onDelete(draft.id)}
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
onClick={() => onSave(draft.id)}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<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
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function RoutingRulesEditor({
|
export function RoutingRulesEditor({
|
||||||
pipelineId,
|
pipelineId,
|
||||||
tracks,
|
tracks,
|
||||||
}: RoutingRulesEditorProps) {
|
}: RoutingRulesEditorProps) {
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const [drafts, setDrafts] = useState<Record<string, RuleDraft>>({})
|
const [drafts, setDrafts] = useState<Record<string, RuleDraft>>({})
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const { data: rules = [], isLoading } = trpc.routing.listRules.useQuery({
|
const { data: rules = [], isLoading } = trpc.routing.listRules.useQuery({
|
||||||
pipelineId,
|
pipelineId,
|
||||||
|
|
@ -468,6 +95,13 @@ export function RoutingRulesEditor({
|
||||||
onError: (error) => toast.error(error.message),
|
onError: (error) => toast.error(error.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const reorderRules = trpc.routing.reorderRules.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.routing.listRules.invalidate({ pipelineId })
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
})
|
||||||
|
|
||||||
const orderedRules = useMemo(
|
const orderedRules = useMemo(
|
||||||
() => [...rules].sort((a, b) => b.priority - a.priority),
|
() => [...rules].sort((a, b) => b.priority - a.priority),
|
||||||
[rules]
|
[rules]
|
||||||
|
|
@ -497,7 +131,7 @@ export function RoutingRulesEditor({
|
||||||
toast.error('Create a track before adding routing rules')
|
toast.error('Create a track before adding routing rules')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = await upsertRule.mutateAsync({
|
await upsertRule.mutateAsync({
|
||||||
pipelineId,
|
pipelineId,
|
||||||
name: `Routing Rule ${orderedRules.length + 1}`,
|
name: `Routing Rule ${orderedRules.length + 1}`,
|
||||||
scope: 'global',
|
scope: 'global',
|
||||||
|
|
@ -508,10 +142,6 @@ export function RoutingRulesEditor({
|
||||||
isActive: true,
|
isActive: true,
|
||||||
predicateJson: DEFAULT_PREDICATE,
|
predicateJson: DEFAULT_PREDICATE,
|
||||||
})
|
})
|
||||||
// Auto-expand the new rule
|
|
||||||
if (result?.id) {
|
|
||||||
setExpandedId(result.id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveRule = async (id: string) => {
|
const handleSaveRule = async (id: string) => {
|
||||||
|
|
@ -532,102 +162,291 @@ export function RoutingRulesEditor({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateDraft = (id: string, updates: Partial<RuleDraft>) => {
|
const handleMoveRule = async (index: number, direction: 'up' | 'down') => {
|
||||||
setDrafts((prev) => ({
|
const targetIndex = direction === 'up' ? index - 1 : index + 1
|
||||||
...prev,
|
if (targetIndex < 0 || targetIndex >= orderedRules.length) return
|
||||||
[id]: { ...prev[id], ...updates },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleExpand = (id: string) => {
|
const reordered = [...orderedRules]
|
||||||
setExpandedId((prev) => (prev === id ? null : id))
|
const temp = reordered[index]
|
||||||
|
reordered[index] = reordered[targetIndex]
|
||||||
|
reordered[targetIndex] = temp
|
||||||
|
|
||||||
|
await reorderRules.mutateAsync({
|
||||||
|
pipelineId,
|
||||||
|
orderedIds: reordered.map((rule) => rule.id),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<Card>
|
||||||
<div className="flex items-center gap-2">
|
<CardHeader>
|
||||||
<Route className="h-4 w-4 text-muted-foreground" />
|
<CardTitle className="text-sm">Routing Rules</CardTitle>
|
||||||
<h3 className="text-sm font-medium">Routing Rules</h3>
|
</CardHeader>
|
||||||
</div>
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground p-4">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Loading routing rules...
|
Loading routing rules...
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<Card>
|
||||||
{/* Section header */}
|
<CardHeader>
|
||||||
<TooltipProvider delayDuration={300}>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<CardTitle className="text-sm">Routing Rules</CardTitle>
|
||||||
<Route className="h-4 w-4 text-muted-foreground" />
|
<Button type="button" size="sm" onClick={handleCreateRule}>
|
||||||
<h3 className="text-sm font-medium">Routing Rules</h3>
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||||
<Tooltip>
|
Add Rule
|
||||||
<TooltipTrigger asChild>
|
</Button>
|
||||||
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="max-w-[280px] text-xs">
|
|
||||||
Routing rules determine which track a project enters based on its attributes. Rules are evaluated in priority order -- the first matching rule wins.
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{orderedRules.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No routing rules configured yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{orderedRules.map((rule, index) => {
|
||||||
|
const draft = drafts[rule.id]
|
||||||
|
if (!draft) return null
|
||||||
|
|
||||||
|
const destinationTrack = tracks.find(
|
||||||
|
(track) => track.id === draft.destinationTrackId
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={rule.id} className="rounded-md border p-3 space-y-3">
|
||||||
|
<div className="grid gap-2 sm:grid-cols-12">
|
||||||
|
<div className="sm:col-span-5 space-y-1">
|
||||||
|
<Label className="text-xs">Rule Name</Label>
|
||||||
|
<Input
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDrafts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[rule.id]: { ...draft, name: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-4 space-y-1">
|
||||||
|
<Label className="text-xs">Scope</Label>
|
||||||
|
<Select
|
||||||
|
value={draft.scope}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setDrafts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[rule.id]: {
|
||||||
|
...draft,
|
||||||
|
scope: value as RuleDraft['scope'],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="global">Global</SelectItem>
|
||||||
|
<SelectItem value="track">Track</SelectItem>
|
||||||
|
<SelectItem value="stage">Stage</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-3 space-y-1">
|
||||||
|
<Label className="text-xs">Priority</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={draft.priority}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDrafts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[rule.id]: {
|
||||||
|
...draft,
|
||||||
|
priority: parseInt(e.target.value, 10) || 0,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Source Track</Label>
|
||||||
|
<Select
|
||||||
|
value={draft.sourceTrackId ?? '__none__'}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setDrafts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[rule.id]: {
|
||||||
|
...draft,
|
||||||
|
sourceTrackId: value === '__none__' ? null : value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">Any Track</SelectItem>
|
||||||
|
{tracks.map((track) => (
|
||||||
|
<SelectItem key={track.id} value={track.id}>
|
||||||
|
{track.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Destination Track</Label>
|
||||||
|
<Select
|
||||||
|
value={draft.destinationTrackId}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const track = tracks.find((t) => t.id === value)
|
||||||
|
setDrafts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[rule.id]: {
|
||||||
|
...draft,
|
||||||
|
destinationTrackId: value,
|
||||||
|
destinationStageId: track?.stages[0]?.id ?? null,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tracks.map((track) => (
|
||||||
|
<SelectItem key={track.id} value={track.id}>
|
||||||
|
{track.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Destination Stage</Label>
|
||||||
|
<Select
|
||||||
|
value={draft.destinationStageId ?? '__none__'}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setDrafts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[rule.id]: {
|
||||||
|
...draft,
|
||||||
|
destinationStageId: value === '__none__' ? null : value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">Track Start</SelectItem>
|
||||||
|
{(destinationTrack?.stages ?? [])
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map((stage) => (
|
||||||
|
<SelectItem key={stage.id} value={stage.id}>
|
||||||
|
{stage.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleMoveRule(index, 'up')}
|
||||||
|
disabled={index === 0 || reorderRules.isPending}
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleMoveRule(index, 'down')}
|
||||||
|
disabled={
|
||||||
|
index === orderedRules.length - 1 || reorderRules.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8"
|
variant="outline"
|
||||||
onClick={handleCreateRule}
|
onClick={() =>
|
||||||
|
toggleRule.mutate({
|
||||||
|
id: rule.id,
|
||||||
|
isActive: !draft.isActive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={toggleRule.isPending}
|
||||||
|
>
|
||||||
|
{draft.isActive ? (
|
||||||
|
<Power className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<PowerOff className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{draft.isActive ? 'Disable' : 'Enable'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleSaveRule(rule.id)}
|
||||||
disabled={upsertRule.isPending}
|
disabled={upsertRule.isPending}
|
||||||
>
|
>
|
||||||
{upsertRule.isPending ? (
|
{upsertRule.isPending ? (
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
Add Rule
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => deleteRule.mutate({ id: rule.id })}
|
||||||
|
disabled={deleteRule.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
{/* Rule list */}
|
|
||||||
{orderedRules.length === 0 ? (
|
|
||||||
<div className="rounded-md border border-dashed p-6 text-center">
|
|
||||||
<Route className="mx-auto h-8 w-8 text-muted-foreground/50 mb-2" />
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">No routing rules yet</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Add a rule to automatically route projects into tracks based on their attributes.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
{orderedRules.map((rule, index) => {
|
|
||||||
const draft = drafts[rule.id]
|
|
||||||
if (!draft) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RuleCard
|
|
||||||
key={rule.id}
|
|
||||||
draft={draft}
|
|
||||||
index={index}
|
|
||||||
tracks={tracks}
|
|
||||||
pipelineId={pipelineId}
|
|
||||||
expandedId={expandedId}
|
|
||||||
onToggleExpand={handleToggleExpand}
|
|
||||||
onUpdateDraft={handleUpdateDraft}
|
|
||||||
onSave={handleSaveRule}
|
|
||||||
onDelete={(id) => deleteRule.mutate({ id })}
|
|
||||||
onToggleActive={(id, isActive) => toggleRule.mutate({ id, isActive })}
|
|
||||||
isSaving={upsertRule.isPending}
|
|
||||||
isDeleting={deleteRule.isPending}
|
|
||||||
isToggling={toggleRule.isPending}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,6 @@ import {
|
||||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||||
import type { EvaluationConfig } from '@/types/pipeline-wizard'
|
import type { EvaluationConfig } from '@/types/pipeline-wizard'
|
||||||
|
|
||||||
const ASSIGNMENT_CATEGORIES = [
|
|
||||||
{ key: 'STARTUP', label: 'Startups' },
|
|
||||||
{ key: 'BUSINESS_CONCEPT', label: 'Business Concepts' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
type AssignmentSectionProps = {
|
type AssignmentSectionProps = {
|
||||||
config: EvaluationConfig
|
config: EvaluationConfig
|
||||||
onChange: (config: EvaluationConfig) => void
|
onChange: (config: EvaluationConfig) => void
|
||||||
|
|
@ -148,96 +143,6 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category Balance */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Label>Balance assignments by category</Label>
|
|
||||||
<InfoTooltip content="Ensure each juror receives a balanced mix of project categories within their assignment limits." />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Set per-category min/max assignment targets per juror
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={config.categoryQuotasEnabled ?? false}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
updateConfig({
|
|
||||||
categoryQuotasEnabled: checked,
|
|
||||||
categoryQuotas: checked
|
|
||||||
? config.categoryQuotas ?? {
|
|
||||||
STARTUP: { min: 0, max: 10 },
|
|
||||||
BUSINESS_CONCEPT: { min: 0, max: 10 },
|
|
||||||
}
|
|
||||||
: config.categoryQuotas,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={isActive}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{config.categoryQuotasEnabled && (
|
|
||||||
<div className="space-y-4 rounded-md border p-4">
|
|
||||||
{ASSIGNMENT_CATEGORIES.map((cat) => {
|
|
||||||
const catQuota = (config.categoryQuotas ?? {})[cat.key] ?? { min: 0, max: 10 }
|
|
||||||
return (
|
|
||||||
<div key={cat.key} className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium">{cat.label}</Label>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs text-muted-foreground">Min per juror</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={50}
|
|
||||||
value={catQuota.min}
|
|
||||||
disabled={isActive}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateConfig({
|
|
||||||
categoryQuotas: {
|
|
||||||
...config.categoryQuotas,
|
|
||||||
[cat.key]: {
|
|
||||||
...catQuota,
|
|
||||||
min: parseInt(e.target.value, 10) || 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs text-muted-foreground">Max per juror</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={catQuota.max}
|
|
||||||
disabled={isActive}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateConfig({
|
|
||||||
categoryQuotas: {
|
|
||||||
...config.categoryQuotas,
|
|
||||||
[cat.key]: {
|
|
||||||
...catQuota,
|
|
||||||
max: parseInt(e.target.value, 10) || 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{catQuota.min > catQuota.max && (
|
|
||||||
<p className="text-xs text-destructive">
|
|
||||||
Min cannot exceed max for {cat.label.toLowerCase()}.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
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 { Switch } from '@/components/ui/switch'
|
|
||||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -19,11 +18,6 @@ type SelectionSectionProps = {
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORIES = [
|
|
||||||
{ key: 'STARTUP', label: 'Startups' },
|
|
||||||
{ key: 'BUSINESS_CONCEPT', label: 'Business Concepts' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export function SelectionSection({
|
export function SelectionSection({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
|
|
@ -33,73 +27,9 @@ export function SelectionSection({
|
||||||
onChange({ ...config, ...updates })
|
onChange({ ...config, ...updates })
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotas = config.categoryQuotas ?? {}
|
|
||||||
const quotaTotal = CATEGORIES.reduce((sum, c) => sum + (quotas[c.key] ?? 0), 0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Label>Per-category quotas</Label>
|
|
||||||
<InfoTooltip content="Set separate finalist targets per competition category. When enabled, projects are selected independently within each category." />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Define finalist targets for each category separately
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={config.categoryQuotasEnabled ?? false}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
updateConfig({
|
|
||||||
categoryQuotasEnabled: checked,
|
|
||||||
categoryQuotas: checked
|
|
||||||
? config.categoryQuotas ?? { STARTUP: 3, BUSINESS_CONCEPT: 3 }
|
|
||||||
: config.categoryQuotas,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={isActive}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{config.categoryQuotasEnabled ? (
|
|
||||||
<>
|
|
||||||
{CATEGORIES.map((cat) => (
|
|
||||||
<div key={cat.key} className="space-y-2">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Label>{cat.label}</Label>
|
|
||||||
<InfoTooltip content={`Finalist target for ${cat.label.toLowerCase()}.`} />
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={250}
|
|
||||||
value={quotas[cat.key] ?? 0}
|
|
||||||
disabled={isActive}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateConfig({
|
|
||||||
categoryQuotas: {
|
|
||||||
...quotas,
|
|
||||||
[cat.key]: parseInt(e.target.value, 10) || 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="sm:col-span-2 text-sm text-muted-foreground">
|
|
||||||
Total: {quotaTotal} finalists (
|
|
||||||
{CATEGORIES.map((c, i) => (
|
|
||||||
<span key={c.key}>
|
|
||||||
{i > 0 && ' + '}
|
|
||||||
{quotas[c.key] ?? 0} {c.label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
)
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Label>Finalist Count</Label>
|
<Label>Finalist Count</Label>
|
||||||
|
|
@ -122,7 +52,6 @@ export function SelectionSection({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Trophy, Users, ArrowUpDown, LayoutGrid } from 'lucide-react'
|
import { Trophy, Users, ArrowUpDown } from 'lucide-react'
|
||||||
import type { SelectionConfig } from '@/types/pipeline-wizard'
|
import type { SelectionConfig } from '@/types/pipeline-wizard'
|
||||||
|
|
||||||
type SelectionPanelProps = {
|
type SelectionPanelProps = {
|
||||||
|
|
@ -14,11 +13,6 @@ type SelectionPanelProps = {
|
||||||
configJson: Record<string, unknown> | null
|
configJson: Record<string, unknown> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
|
||||||
STARTUP: 'Startups',
|
|
||||||
BUSINESS_CONCEPT: 'Business Concepts',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SelectionPanel({ stageId, configJson }: SelectionPanelProps) {
|
export function SelectionPanel({ stageId, configJson }: SelectionPanelProps) {
|
||||||
const config = configJson as unknown as SelectionConfig | null
|
const config = configJson as unknown as SelectionConfig | null
|
||||||
|
|
||||||
|
|
@ -35,33 +29,7 @@ export function SelectionPanel({ stageId, configJson }: SelectionPanelProps) {
|
||||||
(p) => p.state === 'PENDING' || p.state === 'IN_PROGRESS'
|
(p) => p.state === 'PENDING' || p.state === 'IN_PROGRESS'
|
||||||
).length ?? 0
|
).length ?? 0
|
||||||
|
|
||||||
const quotasEnabled = config?.categoryQuotasEnabled ?? false
|
const finalistTarget = config?.finalistCount ?? 6
|
||||||
const quotas = config?.categoryQuotas ?? {}
|
|
||||||
|
|
||||||
const finalistTarget = quotasEnabled
|
|
||||||
? Object.values(quotas).reduce((a, b) => a + b, 0)
|
|
||||||
: (config?.finalistCount ?? 6)
|
|
||||||
|
|
||||||
const categoryBreakdown = useMemo(() => {
|
|
||||||
if (!projectStates?.items) return []
|
|
||||||
const groups: Record<string, { total: number; selected: number }> = {}
|
|
||||||
for (const ps of projectStates.items) {
|
|
||||||
const cat = ps.project.competitionCategory ?? 'UNCATEGORIZED'
|
|
||||||
if (!groups[cat]) groups[cat] = { total: 0, selected: 0 }
|
|
||||||
groups[cat].total++
|
|
||||||
if (ps.state === 'PASSED') groups[cat].selected++
|
|
||||||
}
|
|
||||||
// Sort: known categories first, then uncategorized
|
|
||||||
return Object.entries(groups).sort(([a], [b]) => {
|
|
||||||
if (a === 'UNCATEGORIZED') return 1
|
|
||||||
if (b === 'UNCATEGORIZED') return -1
|
|
||||||
return a.localeCompare(b)
|
|
||||||
})
|
|
||||||
}, [projectStates?.items])
|
|
||||||
|
|
||||||
const finalistDisplay = quotasEnabled
|
|
||||||
? Object.entries(quotas).map((e) => e[1]).join('+')
|
|
||||||
: String(finalistTarget)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -73,10 +41,8 @@ export function SelectionPanel({ stageId, configJson }: SelectionPanelProps) {
|
||||||
<Trophy className="h-4 w-4 text-amber-500" />
|
<Trophy className="h-4 w-4 text-amber-500" />
|
||||||
<span className="text-sm font-medium">Finalist Target</span>
|
<span className="text-sm font-medium">Finalist Target</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold mt-1">{finalistDisplay}</p>
|
<p className="text-2xl font-bold mt-1">{finalistTarget}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">to be selected</p>
|
||||||
{quotasEnabled ? 'per-category quotas' : 'to be selected'}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -105,65 +71,6 @@ export function SelectionPanel({ stageId, configJson }: SelectionPanelProps) {
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category Breakdown */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm flex items-center gap-2">
|
|
||||||
<LayoutGrid className="h-4 w-4" />
|
|
||||||
Category Breakdown
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
</div>
|
|
||||||
) : categoryBreakdown.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground py-2 text-center">
|
|
||||||
No projects to categorize
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{categoryBreakdown.map(([cat, data]) => {
|
|
||||||
const label = CATEGORY_LABELS[cat] ?? (cat === 'UNCATEGORIZED' ? 'Uncategorized' : cat)
|
|
||||||
const quota = quotasEnabled ? (quotas[cat] ?? 0) : 0
|
|
||||||
const target = quotasEnabled ? quota : data.total
|
|
||||||
const pct = target > 0 ? Math.min((data.selected / target) * 100, 100) : 0
|
|
||||||
|
|
||||||
let barColor = ''
|
|
||||||
if (quotasEnabled) {
|
|
||||||
if (data.selected > quota) barColor = '[&>div]:bg-destructive'
|
|
||||||
else if (data.selected === quota) barColor = '[&>div]:bg-emerald-500'
|
|
||||||
else barColor = '[&>div]:bg-amber-500'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={cat} className="space-y-1">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span>{label}</span>
|
|
||||||
<span className="font-medium text-muted-foreground">
|
|
||||||
{data.total} total · {data.selected} selected
|
|
||||||
{quotasEnabled && quota > 0 && (
|
|
||||||
<span className="ml-1">/ {quota} target</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{quotasEnabled ? (
|
|
||||||
<Progress value={pct} className={barColor} />
|
|
||||||
) : (
|
|
||||||
<Progress
|
|
||||||
value={data.total > 0 ? (data.selected / data.total) * 100 : 0}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Selection Progress */}
|
{/* Selection Progress */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { normalizeStageConfig } from '@/lib/stage-config-schema'
|
|
||||||
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
|
|
||||||
|
|
||||||
type TrackInput = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
kind: 'MAIN' | 'AWARD' | 'SHOWCASE'
|
|
||||||
sortOrder: number
|
|
||||||
routingMode: 'PARALLEL' | 'EXCLUSIVE' | 'POST_MAIN' | null
|
|
||||||
decisionMode:
|
|
||||||
| 'JURY_VOTE'
|
|
||||||
| 'AWARD_MASTER_DECISION'
|
|
||||||
| 'ADMIN_DECISION'
|
|
||||||
| null
|
|
||||||
stages: Array<{
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
stageType:
|
|
||||||
| 'INTAKE'
|
|
||||||
| 'FILTER'
|
|
||||||
| 'EVALUATION'
|
|
||||||
| 'SELECTION'
|
|
||||||
| 'LIVE_FINAL'
|
|
||||||
| 'RESULTS'
|
|
||||||
sortOrder: number
|
|
||||||
configJson: unknown
|
|
||||||
}>
|
|
||||||
specialAward?: {
|
|
||||||
name: string
|
|
||||||
description: string | null
|
|
||||||
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toWizardTrackConfig(track: TrackInput): WizardTrackConfig {
|
|
||||||
return {
|
|
||||||
id: track.id,
|
|
||||||
name: track.name,
|
|
||||||
slug: track.slug,
|
|
||||||
kind: track.kind,
|
|
||||||
sortOrder: track.sortOrder,
|
|
||||||
routingModeDefault: track.routingMode ?? undefined,
|
|
||||||
decisionMode: track.decisionMode ?? undefined,
|
|
||||||
stages: track.stages
|
|
||||||
.map((stage) => ({
|
|
||||||
id: stage.id,
|
|
||||||
name: stage.name,
|
|
||||||
slug: stage.slug,
|
|
||||||
stageType: stage.stageType,
|
|
||||||
sortOrder: stage.sortOrder,
|
|
||||||
configJson: normalizeStageConfig(
|
|
||||||
stage.stageType,
|
|
||||||
stage.configJson as Record<string, unknown> | null
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder),
|
|
||||||
awardConfig: track.specialAward
|
|
||||||
? {
|
|
||||||
name: track.specialAward.name,
|
|
||||||
description: track.specialAward.description ?? undefined,
|
|
||||||
scoringMode: track.specialAward.scoringMode,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -52,8 +52,6 @@ export function defaultSelectionConfig(): SelectionConfig {
|
||||||
finalistCount: undefined,
|
finalistCount: undefined,
|
||||||
rankingMethod: 'score_average',
|
rankingMethod: 'score_average',
|
||||||
tieBreaker: 'admin_decides',
|
tieBreaker: 'admin_decides',
|
||||||
categoryQuotasEnabled: false,
|
|
||||||
categoryQuotas: { STARTUP: 3, BUSINESS_CONCEPT: 3 },
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -689,12 +689,6 @@ export const assignmentRouter = router({
|
||||||
(config.maxAssignmentsPerJuror as number) ??
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
20
|
20
|
||||||
|
|
||||||
// Extract category quotas if enabled
|
|
||||||
const categoryQuotasEnabled = config.categoryQuotasEnabled === true
|
|
||||||
const categoryQuotas = categoryQuotasEnabled
|
|
||||||
? (config.categoryQuotas as Record<string, { min: number; max: number }> | undefined)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const jurors = await ctx.prisma.user.findMany({
|
const jurors = await ctx.prisma.user.findMany({
|
||||||
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
|
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
|
||||||
select: {
|
select: {
|
||||||
|
|
@ -723,7 +717,6 @@ export const assignmentRouter = router({
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
competitionCategory: true,
|
|
||||||
projectTags: {
|
projectTags: {
|
||||||
include: { tag: { select: { name: true } } },
|
include: { tag: { select: { name: true } } },
|
||||||
},
|
},
|
||||||
|
|
@ -739,28 +732,6 @@ export const assignmentRouter = router({
|
||||||
existingAssignments.map((a) => `${a.userId}-${a.projectId}`)
|
existingAssignments.map((a) => `${a.userId}-${a.projectId}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build per-juror category distribution for quota scoring
|
|
||||||
const jurorCategoryDistribution = new Map<string, Record<string, number>>()
|
|
||||||
if (categoryQuotas) {
|
|
||||||
const assignmentsWithCategory = await ctx.prisma.assignment.findMany({
|
|
||||||
where: { stageId: input.stageId },
|
|
||||||
select: {
|
|
||||||
userId: true,
|
|
||||||
project: { select: { competitionCategory: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
for (const a of assignmentsWithCategory) {
|
|
||||||
const cat = a.project.competitionCategory?.toLowerCase().trim()
|
|
||||||
if (!cat) continue
|
|
||||||
let catMap = jurorCategoryDistribution.get(a.userId)
|
|
||||||
if (!catMap) {
|
|
||||||
catMap = {}
|
|
||||||
jurorCategoryDistribution.set(a.userId, catMap)
|
|
||||||
}
|
|
||||||
catMap[cat] = (catMap[cat] || 0) + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const suggestions: Array<{
|
const suggestions: Array<{
|
||||||
userId: string
|
userId: string
|
||||||
jurorName: string
|
jurorName: string
|
||||||
|
|
@ -825,34 +796,6 @@ export const assignmentRouter = router({
|
||||||
`Capacity: ${juror._count.assignments}/${effectiveMax} max`
|
`Capacity: ${juror._count.assignments}/${effectiveMax} max`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Category quota scoring
|
|
||||||
if (categoryQuotas) {
|
|
||||||
const jurorCategoryCounts = jurorCategoryDistribution.get(juror.id) || {}
|
|
||||||
const normalizedCat = project.competitionCategory?.toLowerCase().trim()
|
|
||||||
if (normalizedCat) {
|
|
||||||
const quota = Object.entries(categoryQuotas).find(
|
|
||||||
([key]) => key.toLowerCase().trim() === normalizedCat
|
|
||||||
)
|
|
||||||
if (quota) {
|
|
||||||
const [, { min, max }] = quota
|
|
||||||
const currentCount = jurorCategoryCounts[normalizedCat] || 0
|
|
||||||
if (currentCount >= max) {
|
|
||||||
score -= 25
|
|
||||||
reasoning.push(`Category quota exceeded (-25)`)
|
|
||||||
} else if (currentCount < min) {
|
|
||||||
const otherAboveMin = Object.entries(categoryQuotas).some(([key, q]) => {
|
|
||||||
if (key.toLowerCase().trim() === normalizedCat) return false
|
|
||||||
return (jurorCategoryCounts[key.toLowerCase().trim()] || 0) >= q.min
|
|
||||||
})
|
|
||||||
if (otherAboveMin) {
|
|
||||||
score += 10
|
|
||||||
reasoning.push(`Category quota bonus (+10)`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: juror.id,
|
userId: juror.id,
|
||||||
jurorName: juror.name || juror.email || 'Unknown',
|
jurorName: juror.name || juror.email || 'Unknown',
|
||||||
|
|
|
||||||
|
|
@ -11,24 +11,6 @@ import {
|
||||||
NotificationTypes,
|
NotificationTypes,
|
||||||
} from '../services/in-app-notification'
|
} from '../services/in-app-notification'
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract a numeric confidence/quality score from aiScreeningJson.
|
|
||||||
* Looks for common keys: overallScore, confidenceScore, score, qualityScore.
|
|
||||||
* Returns 0 if no score found.
|
|
||||||
*/
|
|
||||||
function getAIConfidenceScore(aiScreeningJson: Prisma.JsonValue | null): number {
|
|
||||||
if (!aiScreeningJson || typeof aiScreeningJson !== 'object' || Array.isArray(aiScreeningJson)) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
const obj = aiScreeningJson as Record<string, unknown>
|
|
||||||
for (const key of ['overallScore', 'confidenceScore', 'score', 'qualityScore']) {
|
|
||||||
if (typeof obj[key] === 'number') {
|
|
||||||
return obj[key] as number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runFilteringJob(jobId: string, stageId: string, userId: string) {
|
export async function runFilteringJob(jobId: string, stageId: string, userId: string) {
|
||||||
try {
|
try {
|
||||||
// Update job to running
|
// Update job to running
|
||||||
|
|
@ -737,12 +719,7 @@ export const filteringRouter = router({
|
||||||
* FILTERED_OUT → mark as REJECTED (data preserved)
|
* FILTERED_OUT → mark as REJECTED (data preserved)
|
||||||
*/
|
*/
|
||||||
finalizeResults: adminProcedure
|
finalizeResults: adminProcedure
|
||||||
.input(
|
.input(z.object({ stageId: z.string() }))
|
||||||
z.object({
|
|
||||||
stageId: z.string(),
|
|
||||||
categoryTargets: z.record(z.number().int().min(0)).optional(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const currentStage = await ctx.prisma.stage.findUniqueOrThrow({
|
const currentStage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||||
where: { id: input.stageId },
|
where: { id: input.stageId },
|
||||||
|
|
@ -760,80 +737,15 @@ export const filteringRouter = router({
|
||||||
|
|
||||||
const results = await ctx.prisma.filteringResult.findMany({
|
const results = await ctx.prisma.filteringResult.findMany({
|
||||||
where: { stageId: input.stageId },
|
where: { stageId: input.stageId },
|
||||||
include: {
|
|
||||||
project: {
|
|
||||||
select: { competitionCategory: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredOutIds = results
|
const filteredOutIds = results
|
||||||
.filter((r) => (r.finalOutcome || r.outcome) === 'FILTERED_OUT')
|
.filter((r) => (r.finalOutcome || r.outcome) === 'FILTERED_OUT')
|
||||||
.map((r) => r.projectId)
|
.map((r) => r.projectId)
|
||||||
|
|
||||||
let passedResults = results
|
const passedIds = results
|
||||||
.filter((r) => (r.finalOutcome || r.outcome) === 'PASSED')
|
.filter((r) => (r.finalOutcome || r.outcome) === 'PASSED')
|
||||||
|
.map((r) => r.projectId)
|
||||||
// Apply category targets if provided
|
|
||||||
const categoryWarnings: string[] = []
|
|
||||||
const categoryCounts: Record<string, number> = {}
|
|
||||||
const demotedIds: string[] = []
|
|
||||||
|
|
||||||
if (input.categoryTargets && Object.keys(input.categoryTargets).length > 0) {
|
|
||||||
// Group passing projects by category
|
|
||||||
const passedByCategory = new Map<string, typeof passedResults>()
|
|
||||||
for (const r of passedResults) {
|
|
||||||
const cat = r.project.competitionCategory?.toLowerCase().trim() || '_uncategorized'
|
|
||||||
const existing = passedByCategory.get(cat) || []
|
|
||||||
existing.push(r)
|
|
||||||
passedByCategory.set(cat, existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check each category against its target
|
|
||||||
for (const [cat, target] of Object.entries(input.categoryTargets)) {
|
|
||||||
const normalizedCat = cat.toLowerCase().trim()
|
|
||||||
const catResults = passedByCategory.get(normalizedCat) || []
|
|
||||||
categoryCounts[cat] = catResults.length
|
|
||||||
|
|
||||||
if (catResults.length > target) {
|
|
||||||
// Sort by AI confidence score (descending) to keep the best
|
|
||||||
const sorted = catResults.sort((a, b) => {
|
|
||||||
const scoreA = getAIConfidenceScore(a.aiScreeningJson)
|
|
||||||
const scoreB = getAIConfidenceScore(b.aiScreeningJson)
|
|
||||||
return scoreB - scoreA
|
|
||||||
})
|
|
||||||
|
|
||||||
// Demote the lowest-ranked excess to FLAGGED
|
|
||||||
const excess = sorted.slice(target)
|
|
||||||
for (const r of excess) {
|
|
||||||
demotedIds.push(r.id)
|
|
||||||
}
|
|
||||||
} else if (catResults.length < target) {
|
|
||||||
categoryWarnings.push(
|
|
||||||
`Category "${cat}" is below target: ${catResults.length}/${target}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also count categories not in targets
|
|
||||||
for (const [cat, catResults] of passedByCategory) {
|
|
||||||
if (!Object.keys(input.categoryTargets).some((k) => k.toLowerCase().trim() === cat)) {
|
|
||||||
categoryCounts[cat] = catResults.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove demoted from passedResults
|
|
||||||
const demotedIdSet = new Set(demotedIds)
|
|
||||||
passedResults = passedResults.filter((r) => !demotedIdSet.has(r.id))
|
|
||||||
} else {
|
|
||||||
// Build category counts even without targets
|
|
||||||
for (const r of passedResults) {
|
|
||||||
const cat = r.project.competitionCategory || '_uncategorized'
|
|
||||||
categoryCounts[cat] = (categoryCounts[cat] || 0) + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const passedIds = passedResults.map((r) => r.projectId)
|
|
||||||
|
|
||||||
const operations: Prisma.PrismaPromise<unknown>[] = []
|
const operations: Prisma.PrismaPromise<unknown>[] = []
|
||||||
|
|
||||||
|
|
@ -855,21 +767,6 @@ export const filteringRouter = router({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update demoted results to FLAGGED outcome
|
|
||||||
if (demotedIds.length > 0) {
|
|
||||||
operations.push(
|
|
||||||
ctx.prisma.filteringResult.updateMany({
|
|
||||||
where: { id: { in: demotedIds } },
|
|
||||||
data: {
|
|
||||||
finalOutcome: 'FLAGGED',
|
|
||||||
overriddenBy: ctx.user.id,
|
|
||||||
overriddenAt: new Date(),
|
|
||||||
overrideReason: 'Demoted by category target enforcement',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.prisma.$transaction(operations)
|
await ctx.prisma.$transaction(operations)
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
|
|
@ -881,9 +778,6 @@ export const filteringRouter = router({
|
||||||
action: 'FINALIZE_FILTERING',
|
action: 'FINALIZE_FILTERING',
|
||||||
passed: passedIds.length,
|
passed: passedIds.length,
|
||||||
filteredOut: filteredOutIds.length,
|
filteredOut: filteredOutIds.length,
|
||||||
demotedToFlagged: demotedIds.length,
|
|
||||||
categoryTargets: input.categoryTargets || null,
|
|
||||||
categoryWarnings,
|
|
||||||
advancedToStage: nextStage?.name || null,
|
advancedToStage: nextStage?.name || null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -891,9 +785,6 @@ export const filteringRouter = router({
|
||||||
return {
|
return {
|
||||||
passed: passedIds.length,
|
passed: passedIds.length,
|
||||||
filteredOut: filteredOutIds.length,
|
filteredOut: filteredOutIds.length,
|
||||||
demotedToFlagged: demotedIds.length,
|
|
||||||
categoryCounts,
|
|
||||||
categoryWarnings,
|
|
||||||
advancedToStageId: nextStage?.id || null,
|
advancedToStageId: nextStage?.id || null,
|
||||||
advancedToStageName: nextStage?.name || null,
|
advancedToStageName: nextStage?.name || null,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ import { TRPCError } from '@trpc/server'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import { router, adminProcedure } from '../trpc'
|
import { router, adminProcedure } from '../trpc'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
|
||||||
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
|
||||||
import {
|
import {
|
||||||
previewRouting,
|
previewRouting,
|
||||||
evaluateRoutingRules,
|
evaluateRoutingRules,
|
||||||
|
|
@ -382,138 +380,4 @@ export const routingRouter = router({
|
||||||
|
|
||||||
return rule
|
return rule
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse natural language into a routing rule predicate using AI
|
|
||||||
*/
|
|
||||||
parseNaturalLanguageRule: adminProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
text: z.string().min(1).max(500),
|
|
||||||
pipelineId: z.string(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const openai = await getOpenAI()
|
|
||||||
if (!openai) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'PRECONDITION_FAILED',
|
|
||||||
message: 'OpenAI is not configured. Go to Settings to set up the API key.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load pipeline tracks for context
|
|
||||||
const tracks = await ctx.prisma.track.findMany({
|
|
||||||
where: { pipelineId: input.pipelineId },
|
|
||||||
select: { id: true, name: true },
|
|
||||||
orderBy: { sortOrder: 'asc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const trackNames = tracks.map((t) => t.name).join(', ')
|
|
||||||
|
|
||||||
const model = await getConfiguredModel()
|
|
||||||
|
|
||||||
const systemPrompt = `You are a routing rule parser for a project management pipeline.
|
|
||||||
Convert the user's natural language description into a structured predicate JSON.
|
|
||||||
|
|
||||||
Available fields:
|
|
||||||
- competitionCategory: The project's competition category (string values like "STARTUP", "BUSINESS_CONCEPT")
|
|
||||||
- oceanIssue: The ocean issue the project addresses (string)
|
|
||||||
- country: The project's country of origin (string)
|
|
||||||
- geographicZone: The geographic zone (string)
|
|
||||||
- wantsMentorship: Whether the project wants mentorship (boolean: true/false)
|
|
||||||
- tags: Project tags (array of strings)
|
|
||||||
|
|
||||||
Available operators:
|
|
||||||
- eq: equals (exact match)
|
|
||||||
- neq: not equals
|
|
||||||
- in: value is in a list
|
|
||||||
- contains: string contains substring
|
|
||||||
- gt: greater than (numeric)
|
|
||||||
- lt: less than (numeric)
|
|
||||||
|
|
||||||
Predicate format:
|
|
||||||
- Simple condition: { "field": "<field>", "operator": "<op>", "value": "<value>" }
|
|
||||||
- Compound (AND): { "logic": "and", "conditions": [<condition>, ...] }
|
|
||||||
- Compound (OR): { "logic": "or", "conditions": [<condition>, ...] }
|
|
||||||
|
|
||||||
For boolean fields (wantsMentorship), use value: true or value: false (not strings).
|
|
||||||
For "in" operator, value should be an array: ["VALUE1", "VALUE2"].
|
|
||||||
|
|
||||||
Pipeline tracks: ${trackNames || 'None configured yet'}
|
|
||||||
|
|
||||||
Return a JSON object with two keys:
|
|
||||||
- "predicate": the predicate JSON object
|
|
||||||
- "explanation": a brief human-readable explanation of what the rule matches
|
|
||||||
|
|
||||||
Example input: "projects from France or Monaco that are startups"
|
|
||||||
Example output:
|
|
||||||
{
|
|
||||||
"predicate": {
|
|
||||||
"logic": "and",
|
|
||||||
"conditions": [
|
|
||||||
{ "field": "country", "operator": "in", "value": ["France", "Monaco"] },
|
|
||||||
{ "field": "competitionCategory", "operator": "eq", "value": "STARTUP" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"explanation": "Matches projects from France or Monaco with competition category STARTUP"
|
|
||||||
}`
|
|
||||||
|
|
||||||
const params = buildCompletionParams(model, {
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: systemPrompt },
|
|
||||||
{ role: 'user', content: input.text },
|
|
||||||
],
|
|
||||||
maxTokens: 1000,
|
|
||||||
temperature: 0.1,
|
|
||||||
jsonMode: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await openai.chat.completions.create(params)
|
|
||||||
|
|
||||||
const content = response.choices[0]?.message?.content
|
|
||||||
if (!content) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'INTERNAL_SERVER_ERROR',
|
|
||||||
message: 'AI returned an empty response',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log AI usage
|
|
||||||
const tokenUsage = extractTokenUsage(response)
|
|
||||||
await logAIUsage({
|
|
||||||
userId: ctx.user.id,
|
|
||||||
action: 'ROUTING',
|
|
||||||
entityType: 'Pipeline',
|
|
||||||
entityId: input.pipelineId,
|
|
||||||
model,
|
|
||||||
...tokenUsage,
|
|
||||||
itemsProcessed: 1,
|
|
||||||
status: 'SUCCESS',
|
|
||||||
detailsJson: { input: input.text },
|
|
||||||
})
|
|
||||||
|
|
||||||
// Parse the response
|
|
||||||
let parsed: { predicate: Record<string, unknown>; explanation: string }
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(content) as { predicate: Record<string, unknown>; explanation: string }
|
|
||||||
} catch {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'INTERNAL_SERVER_ERROR',
|
|
||||||
message: 'AI returned invalid JSON. Try rephrasing your rule.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed.predicate || typeof parsed.predicate !== 'object') {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'INTERNAL_SERVER_ERROR',
|
|
||||||
message: 'AI response missing predicate. Try rephrasing your rule.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
predicateJson: parsed.predicate,
|
|
||||||
explanation: parsed.explanation || 'Parsed routing rule',
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -723,7 +723,6 @@ export const stageRouter = router({
|
||||||
status: true,
|
status: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
teamName: true,
|
teamName: true,
|
||||||
competitionCategory: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ export interface ScoreBreakdown {
|
||||||
previousRoundFamiliarity: number
|
previousRoundFamiliarity: number
|
||||||
coiPenalty: number
|
coiPenalty: number
|
||||||
availabilityPenalty: number
|
availabilityPenalty: number
|
||||||
categoryQuotaPenalty: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssignmentScore {
|
export interface AssignmentScore {
|
||||||
|
|
@ -70,8 +69,6 @@ const GEO_DIVERSITY_PENALTY_PER_EXCESS = -15
|
||||||
const PREVIOUS_ROUND_FAMILIARITY_BONUS = 10
|
const PREVIOUS_ROUND_FAMILIARITY_BONUS = 10
|
||||||
// COI jurors are skipped entirely rather than penalized (effectively -Infinity)
|
// COI jurors are skipped entirely rather than penalized (effectively -Infinity)
|
||||||
const AVAILABILITY_PENALTY = -30 // Heavy penalty for unavailable jurors
|
const AVAILABILITY_PENALTY = -30 // Heavy penalty for unavailable jurors
|
||||||
const CATEGORY_QUOTA_PENALTY = -25 // Heavy penalty when juror exceeds category max
|
|
||||||
const CATEGORY_QUOTA_BONUS = 10 // Bonus when juror is below category min
|
|
||||||
|
|
||||||
// Common words to exclude from bio matching
|
// Common words to exclude from bio matching
|
||||||
const STOP_WORDS = new Set([
|
const STOP_WORDS = new Set([
|
||||||
|
|
@ -270,50 +267,6 @@ export function calculateAvailabilityPenalty(
|
||||||
return AVAILABILITY_PENALTY
|
return AVAILABILITY_PENALTY
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate category quota penalty/bonus for a juror-project pair.
|
|
||||||
* - If the juror's count for the project's category >= max quota, apply heavy penalty (-25)
|
|
||||||
* - If the juror's count is below min and other categories are above their min, apply bonus (+10)
|
|
||||||
* - Otherwise return 0
|
|
||||||
*/
|
|
||||||
export function calculateCategoryQuotaPenalty(
|
|
||||||
categoryQuotas: Record<string, { min: number; max: number }>,
|
|
||||||
jurorCategoryCounts: Record<string, number>,
|
|
||||||
projectCategory: string | null | undefined
|
|
||||||
): number {
|
|
||||||
if (!projectCategory) return 0
|
|
||||||
|
|
||||||
const normalizedCategory = projectCategory.toLowerCase().trim()
|
|
||||||
const quota = Object.entries(categoryQuotas).find(
|
|
||||||
([key]) => key.toLowerCase().trim() === normalizedCategory
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!quota) return 0
|
|
||||||
|
|
||||||
const [, { min, max }] = quota
|
|
||||||
const currentCount = jurorCategoryCounts[normalizedCategory] || 0
|
|
||||||
|
|
||||||
// If at or over max, heavy penalty
|
|
||||||
if (currentCount >= max) {
|
|
||||||
return CATEGORY_QUOTA_PENALTY
|
|
||||||
}
|
|
||||||
|
|
||||||
// If below min and other categories are above their min, give bonus
|
|
||||||
if (currentCount < min) {
|
|
||||||
const otherCategoriesAboveMin = Object.entries(categoryQuotas).some(([key, q]) => {
|
|
||||||
if (key.toLowerCase().trim() === normalizedCategory) return false
|
|
||||||
const count = jurorCategoryCounts[key.toLowerCase().trim()] || 0
|
|
||||||
return count >= q.min
|
|
||||||
})
|
|
||||||
|
|
||||||
if (otherCategoriesAboveMin) {
|
|
||||||
return CATEGORY_QUOTA_BONUS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Scoring Function ───────────────────────────────────────────────────
|
// ─── Main Scoring Function ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -324,9 +277,8 @@ export async function getSmartSuggestions(options: {
|
||||||
type: 'jury' | 'mentor'
|
type: 'jury' | 'mentor'
|
||||||
limit?: number
|
limit?: number
|
||||||
aiMaxPerJudge?: number
|
aiMaxPerJudge?: number
|
||||||
categoryQuotas?: Record<string, { min: number; max: number }>
|
|
||||||
}): Promise<AssignmentScore[]> {
|
}): Promise<AssignmentScore[]> {
|
||||||
const { stageId, type, limit = 50, aiMaxPerJudge = 20, categoryQuotas } = options
|
const { stageId, type, limit = 50, aiMaxPerJudge = 20 } = options
|
||||||
|
|
||||||
const projectStageStates = await prisma.projectStageState.findMany({
|
const projectStageStates = await prisma.projectStageState.findMany({
|
||||||
where: { stageId },
|
where: { stageId },
|
||||||
|
|
@ -345,7 +297,6 @@ export async function getSmartSuggestions(options: {
|
||||||
teamName: true,
|
teamName: true,
|
||||||
description: true,
|
description: true,
|
||||||
country: true,
|
country: true,
|
||||||
competitionCategory: true,
|
|
||||||
status: true,
|
status: true,
|
||||||
projectTags: {
|
projectTags: {
|
||||||
include: { tag: true },
|
include: { tag: true },
|
||||||
|
|
@ -421,28 +372,6 @@ export async function getSmartSuggestions(options: {
|
||||||
countryMap.set(country, (countryMap.get(country) || 0) + 1)
|
countryMap.set(country, (countryMap.get(country) || 0) + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build map: userId -> { category -> count } for category quota scoring
|
|
||||||
const userCategoryDistribution = new Map<string, Record<string, number>>()
|
|
||||||
if (categoryQuotas) {
|
|
||||||
const assignmentsWithCategory = await prisma.assignment.findMany({
|
|
||||||
where: { stageId },
|
|
||||||
select: {
|
|
||||||
userId: true,
|
|
||||||
project: { select: { competitionCategory: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
for (const a of assignmentsWithCategory) {
|
|
||||||
const category = a.project.competitionCategory?.toLowerCase().trim()
|
|
||||||
if (!category) continue
|
|
||||||
let categoryMap = userCategoryDistribution.get(a.userId)
|
|
||||||
if (!categoryMap) {
|
|
||||||
categoryMap = {}
|
|
||||||
userCategoryDistribution.set(a.userId, categoryMap)
|
|
||||||
}
|
|
||||||
categoryMap[category] = (categoryMap[category] || 0) + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentStage = await prisma.stage.findUnique({
|
const currentStage = await prisma.stage.findUnique({
|
||||||
where: { id: stageId },
|
where: { id: stageId },
|
||||||
select: { trackId: true, sortOrder: true },
|
select: { trackId: true, sortOrder: true },
|
||||||
|
|
@ -556,17 +485,6 @@ export async function getSmartSuggestions(options: {
|
||||||
|
|
||||||
// ── New scoring factors ─────────────────────────────────────────────
|
// ── New scoring factors ─────────────────────────────────────────────
|
||||||
|
|
||||||
// Category quota penalty/bonus
|
|
||||||
let categoryQuotaPenalty = 0
|
|
||||||
if (categoryQuotas) {
|
|
||||||
const jurorCategoryCounts = userCategoryDistribution.get(user.id) || {}
|
|
||||||
categoryQuotaPenalty = calculateCategoryQuotaPenalty(
|
|
||||||
categoryQuotas,
|
|
||||||
jurorCategoryCounts,
|
|
||||||
project.competitionCategory
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Geographic diversity penalty
|
// Geographic diversity penalty
|
||||||
let geoDiversityPenalty = 0
|
let geoDiversityPenalty = 0
|
||||||
const projectCountry = project.country?.toLowerCase().trim()
|
const projectCountry = project.country?.toLowerCase().trim()
|
||||||
|
|
@ -592,8 +510,7 @@ export async function getSmartSuggestions(options: {
|
||||||
countryScore +
|
countryScore +
|
||||||
geoDiversityPenalty +
|
geoDiversityPenalty +
|
||||||
previousRoundFamiliarity +
|
previousRoundFamiliarity +
|
||||||
availabilityPenalty +
|
availabilityPenalty
|
||||||
categoryQuotaPenalty
|
|
||||||
|
|
||||||
// Build reasoning
|
// Build reasoning
|
||||||
const reasoning: string[] = []
|
const reasoning: string[] = []
|
||||||
|
|
@ -623,11 +540,6 @@ export async function getSmartSuggestions(options: {
|
||||||
if (availabilityPenalty < 0) {
|
if (availabilityPenalty < 0) {
|
||||||
reasoning.push(`Unavailable during voting window (${availabilityPenalty})`)
|
reasoning.push(`Unavailable during voting window (${availabilityPenalty})`)
|
||||||
}
|
}
|
||||||
if (categoryQuotaPenalty < 0) {
|
|
||||||
reasoning.push(`Category quota exceeded (${categoryQuotaPenalty})`)
|
|
||||||
} else if (categoryQuotaPenalty > 0) {
|
|
||||||
reasoning.push(`Category quota bonus (+${categoryQuotaPenalty})`)
|
|
||||||
}
|
|
||||||
|
|
||||||
suggestions.push({
|
suggestions.push({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|
@ -645,7 +557,6 @@ export async function getSmartSuggestions(options: {
|
||||||
previousRoundFamiliarity,
|
previousRoundFamiliarity,
|
||||||
coiPenalty: 0, // COI jurors are skipped entirely
|
coiPenalty: 0, // COI jurors are skipped entirely
|
||||||
availabilityPenalty,
|
availabilityPenalty,
|
||||||
categoryQuotaPenalty,
|
|
||||||
},
|
},
|
||||||
reasoning,
|
reasoning,
|
||||||
matchingTags,
|
matchingTags,
|
||||||
|
|
@ -779,7 +690,6 @@ export async function getMentorSuggestionsForProject(
|
||||||
previousRoundFamiliarity: 0,
|
previousRoundFamiliarity: 0,
|
||||||
coiPenalty: 0,
|
coiPenalty: 0,
|
||||||
availabilityPenalty: 0,
|
availabilityPenalty: 0,
|
||||||
categoryQuotaPenalty: 0,
|
|
||||||
},
|
},
|
||||||
reasoning,
|
reasoning,
|
||||||
matchingTags,
|
matchingTags,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ export type AIAction =
|
||||||
| 'MENTOR_MATCHING'
|
| 'MENTOR_MATCHING'
|
||||||
| 'PROJECT_TAGGING'
|
| 'PROJECT_TAGGING'
|
||||||
| 'EVALUATION_SUMMARY'
|
| 'EVALUATION_SUMMARY'
|
||||||
| 'ROUTING'
|
|
||||||
|
|
||||||
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'
|
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,16 +44,12 @@ export type EvaluationConfig = {
|
||||||
minLoadPerJuror: number
|
minLoadPerJuror: number
|
||||||
availabilityWeighting: boolean
|
availabilityWeighting: boolean
|
||||||
overflowPolicy: 'queue' | 'expand_pool' | 'reduce_reviews'
|
overflowPolicy: 'queue' | 'expand_pool' | 'reduce_reviews'
|
||||||
categoryQuotasEnabled?: boolean
|
|
||||||
categoryQuotas?: Record<string, { min: number; max: number }>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SelectionConfig = {
|
export type SelectionConfig = {
|
||||||
finalistCount?: number
|
finalistCount?: number
|
||||||
rankingMethod: 'score_average' | 'weighted_criteria' | 'binary_pass'
|
rankingMethod: 'score_average' | 'weighted_criteria' | 'binary_pass'
|
||||||
tieBreaker: 'admin_decides' | 'highest_individual' | 'revote'
|
tieBreaker: 'admin_decides' | 'highest_individual' | 'revote'
|
||||||
categoryQuotasEnabled?: boolean
|
|
||||||
categoryQuotas?: Record<string, number>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LiveFinalConfig = {
|
export type LiveFinalConfig = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue