Pipeline UX: clickable cards, wizard edit, routing rules redesign, category quotas
Build and Push Docker Image / build (push) Failing after 9s
Details
Build and Push Docker Image / build (push) Failing after 9s
Details
- Simplify pipeline list cards: whole card is clickable, remove clutter - Add wizard edit page for existing pipelines with full state pre-population - Extract toWizardTrackConfig to shared utility for reuse - Rewrite predicate builder with 3 modes: Simple (sentence-style), AI (NLP), Advanced (JSON) - Fix routing operators to match backend (eq/neq/in/contains/gt/lt) - Rewrite routing rules editor with collapsible cards and natural language summaries - Add parseNaturalLanguageRule AI procedure for routing rules - Add per-category quotas to SelectionConfig and EvaluationConfig - Add category quota UI toggles to selection and assignment sections - Add category breakdown display to selection panel - Add category-aware scoring to smart assignment (penalty/bonus) - Add category-aware filtering targets with excess demotion Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c634982835
commit
382570cebd
|
|
@ -20,6 +20,7 @@ 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,
|
||||||
|
|
@ -30,6 +31,7 @@ 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'
|
||||||
|
|
@ -41,8 +43,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> = {
|
||||||
|
|
@ -52,71 +54,6 @@ 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
|
||||||
|
|
@ -450,6 +387,13 @@ 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}
|
||||||
|
|
@ -660,10 +604,6 @@ 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}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,410 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
|
import { useRouter, useParams } from 'next/navigation'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
|
||||||
|
import type { StepConfig } from '@/components/ui/sidebar-stepper'
|
||||||
|
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
|
||||||
|
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
||||||
|
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
||||||
|
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
|
||||||
|
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
|
||||||
|
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
||||||
|
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
|
||||||
|
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
||||||
|
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
|
||||||
|
|
||||||
|
import { defaultNotificationConfig, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
|
||||||
|
import { toWizardTrackConfig } from '@/lib/pipeline-conversions'
|
||||||
|
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
|
||||||
|
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
|
||||||
|
|
||||||
|
export default function EditPipelineWizardPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const params = useParams()
|
||||||
|
const pipelineId = params.id as string
|
||||||
|
|
||||||
|
const [state, setState] = useState<WizardState | null>(null)
|
||||||
|
const [currentStep, setCurrentStep] = useState(0)
|
||||||
|
const initialStateRef = useRef<string>('')
|
||||||
|
|
||||||
|
// Load existing pipeline data
|
||||||
|
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery(
|
||||||
|
{ id: pipelineId },
|
||||||
|
{ enabled: !!pipelineId }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize state when pipeline data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (pipeline && !state) {
|
||||||
|
const settings = (pipeline.settingsJson as Record<string, unknown> | null) ?? {}
|
||||||
|
const initialState: WizardState = {
|
||||||
|
name: pipeline.name,
|
||||||
|
slug: pipeline.slug,
|
||||||
|
programId: pipeline.programId,
|
||||||
|
settingsJson: settings,
|
||||||
|
tracks: pipeline.tracks
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map(track => toWizardTrackConfig({
|
||||||
|
id: track.id,
|
||||||
|
name: track.name,
|
||||||
|
slug: track.slug,
|
||||||
|
kind: track.kind as 'MAIN' | 'AWARD' | 'SHOWCASE',
|
||||||
|
sortOrder: track.sortOrder,
|
||||||
|
routingMode: track.routingMode as 'PARALLEL' | 'EXCLUSIVE' | 'POST_MAIN' | null,
|
||||||
|
decisionMode: track.decisionMode as 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION' | null,
|
||||||
|
stages: track.stages.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
slug: s.slug,
|
||||||
|
stageType: s.stageType as 'INTAKE' | 'FILTER' | 'EVALUATION' | 'SELECTION' | 'LIVE_FINAL' | 'RESULTS',
|
||||||
|
sortOrder: s.sortOrder,
|
||||||
|
configJson: s.configJson,
|
||||||
|
})),
|
||||||
|
specialAward: track.specialAward ? {
|
||||||
|
name: track.specialAward.name,
|
||||||
|
description: track.specialAward.description,
|
||||||
|
scoringMode: track.specialAward.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED',
|
||||||
|
} : null,
|
||||||
|
})),
|
||||||
|
notificationConfig: (settings.notificationConfig as Record<string, boolean>) ?? defaultNotificationConfig(),
|
||||||
|
overridePolicy: (settings.overridePolicy as Record<string, unknown>) ?? { allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
||||||
|
}
|
||||||
|
setState(initialState)
|
||||||
|
initialStateRef.current = JSON.stringify(initialState)
|
||||||
|
}
|
||||||
|
}, [pipeline, state])
|
||||||
|
|
||||||
|
// Dirty tracking — warn on navigate away
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (state && JSON.stringify(state) !== initialStateRef.current) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
const updateState = useCallback((updates: Partial<WizardState>) => {
|
||||||
|
setState((prev) => prev ? { ...prev, ...updates } : prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateSettingsMutation = trpc.pipeline.update.useMutation({
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const publishMutation = trpc.pipeline.publish.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Pipeline published successfully')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = async (publish: boolean) => {
|
||||||
|
if (!state) return
|
||||||
|
|
||||||
|
const validation = validateAll(state)
|
||||||
|
if (!validation.valid) {
|
||||||
|
toast.error('Please fix validation errors before saving')
|
||||||
|
if (!validation.sections.basics.valid) setCurrentStep(0)
|
||||||
|
else if (!validation.sections.tracks.valid) setCurrentStep(2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateStructureMutation.mutateAsync({
|
||||||
|
id: pipelineId,
|
||||||
|
name: state.name,
|
||||||
|
slug: state.slug,
|
||||||
|
settingsJson: {
|
||||||
|
...state.settingsJson,
|
||||||
|
notificationConfig: state.notificationConfig,
|
||||||
|
overridePolicy: state.overridePolicy,
|
||||||
|
},
|
||||||
|
tracks: state.tracks.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
slug: t.slug,
|
||||||
|
kind: t.kind,
|
||||||
|
sortOrder: t.sortOrder,
|
||||||
|
routingModeDefault: t.routingModeDefault,
|
||||||
|
decisionMode: t.decisionMode,
|
||||||
|
stages: t.stages.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
slug: s.slug,
|
||||||
|
stageType: s.stageType,
|
||||||
|
sortOrder: s.sortOrder,
|
||||||
|
configJson: s.configJson,
|
||||||
|
})),
|
||||||
|
awardConfig: t.awardConfig,
|
||||||
|
})),
|
||||||
|
autoTransitions: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await updateSettingsMutation.mutateAsync({
|
||||||
|
id: pipelineId,
|
||||||
|
settingsJson: {
|
||||||
|
...state.settingsJson,
|
||||||
|
notificationConfig: state.notificationConfig,
|
||||||
|
overridePolicy: state.overridePolicy,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (publish) {
|
||||||
|
await publishMutation.mutateAsync({ id: pipelineId })
|
||||||
|
}
|
||||||
|
|
||||||
|
initialStateRef.current = JSON.stringify(state)
|
||||||
|
toast.success(publish ? 'Pipeline saved and published' : 'Pipeline changes saved')
|
||||||
|
router.push(`/admin/rounds/pipeline/${pipelineId}` as Route)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSaving = updateStructureMutation.isPending && !publishMutation.isPending
|
||||||
|
const isSubmitting = publishMutation.isPending
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading || !state) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href={`/admin/rounds/pipeline/${pipelineId}` as Route}>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Edit Pipeline (Wizard)</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Loading pipeline data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stage configs from the main track
|
||||||
|
const mainTrack = state.tracks.find((t) => t.kind === 'MAIN')
|
||||||
|
const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE')
|
||||||
|
const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER')
|
||||||
|
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
|
||||||
|
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
|
||||||
|
|
||||||
|
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
|
||||||
|
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
|
||||||
|
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
|
||||||
|
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
|
||||||
|
|
||||||
|
const updateStageConfig = (stageType: string, configJson: Record<string, unknown>) => {
|
||||||
|
setState((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
tracks: prev.tracks.map((track) => {
|
||||||
|
if (track.kind !== 'MAIN') return track
|
||||||
|
return {
|
||||||
|
...track,
|
||||||
|
stages: track.stages.map((stage) =>
|
||||||
|
stage.stageType === stageType ? { ...stage, configJson } : stage
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMainTrackStages = (stages: WizardState['tracks'][0]['stages']) => {
|
||||||
|
setState((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
tracks: prev.tracks.map((track) =>
|
||||||
|
track.kind === 'MAIN' ? { ...track, stages } : track
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const basicsValid = validateBasics(state).valid
|
||||||
|
const tracksValid = validateTracks(state.tracks).valid
|
||||||
|
const allValid = validateAll(state).valid
|
||||||
|
|
||||||
|
// Step configuration
|
||||||
|
const steps: StepConfig[] = [
|
||||||
|
{
|
||||||
|
title: 'Basics',
|
||||||
|
description: 'Pipeline name and program',
|
||||||
|
isValid: basicsValid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Intake',
|
||||||
|
description: 'Submission window & files',
|
||||||
|
isValid: !!intakeStage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Main Track Stages',
|
||||||
|
description: 'Configure pipeline stages',
|
||||||
|
isValid: tracksValid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Screening',
|
||||||
|
description: 'Gate rules and AI screening',
|
||||||
|
isValid: !!filterStage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Evaluation',
|
||||||
|
description: 'Jury assignment strategy',
|
||||||
|
isValid: !!evalStage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Awards',
|
||||||
|
description: 'Special award tracks',
|
||||||
|
isValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Live Finals',
|
||||||
|
description: 'Voting and reveal settings',
|
||||||
|
isValid: !!liveStage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Notifications',
|
||||||
|
description: 'Event notifications',
|
||||||
|
isValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Review & Save',
|
||||||
|
description: 'Validation summary',
|
||||||
|
isValid: allValid,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 pb-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href={`/admin/rounds/pipeline/${pipelineId}` as Route}>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Edit Pipeline (Wizard)</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Modify the pipeline structure for project evaluation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Stepper */}
|
||||||
|
<SidebarStepper
|
||||||
|
steps={steps}
|
||||||
|
currentStep={currentStep}
|
||||||
|
onStepChange={setCurrentStep}
|
||||||
|
onSave={() => handleSave(false)}
|
||||||
|
onSubmit={() => handleSave(true)}
|
||||||
|
isSaving={isSaving}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
saveLabel="Save Changes"
|
||||||
|
submitLabel="Save & Publish"
|
||||||
|
canSubmit={allValid}
|
||||||
|
>
|
||||||
|
{/* Step 0: Basics */}
|
||||||
|
<div>
|
||||||
|
<BasicsSection state={state} onChange={updateState} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 1: Intake */}
|
||||||
|
<div>
|
||||||
|
<IntakeSection
|
||||||
|
config={intakeConfig}
|
||||||
|
onChange={(c) =>
|
||||||
|
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2: Main Track Stages */}
|
||||||
|
<div>
|
||||||
|
<MainTrackSection
|
||||||
|
stages={mainTrack?.stages ?? []}
|
||||||
|
onChange={updateMainTrackStages}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 3: Screening */}
|
||||||
|
<div>
|
||||||
|
<FilteringSection
|
||||||
|
config={filterConfig}
|
||||||
|
onChange={(c) =>
|
||||||
|
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 4: Evaluation */}
|
||||||
|
<div>
|
||||||
|
<AssignmentSection
|
||||||
|
config={evalConfig}
|
||||||
|
onChange={(c) =>
|
||||||
|
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 5: Awards */}
|
||||||
|
<div>
|
||||||
|
<AwardsSection
|
||||||
|
tracks={state.tracks}
|
||||||
|
onChange={(tracks) => updateState({ tracks })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 6: Live Finals */}
|
||||||
|
<div>
|
||||||
|
<LiveFinalsSection
|
||||||
|
config={liveConfig}
|
||||||
|
onChange={(c) =>
|
||||||
|
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 7: Notifications */}
|
||||||
|
<div>
|
||||||
|
<NotificationsSection
|
||||||
|
config={state.notificationConfig}
|
||||||
|
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
||||||
|
overridePolicy={state.overridePolicy}
|
||||||
|
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 8: Review & Save */}
|
||||||
|
<div>
|
||||||
|
<ReviewSection state={state} />
|
||||||
|
</div>
|
||||||
|
</SidebarStepper>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -15,10 +15,8 @@ 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'
|
||||||
|
|
@ -149,18 +147,14 @@ 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 (
|
||||||
<Card key={pipeline.id} className="group hover:shadow-md transition-shadow h-full flex flex-col">
|
<Link key={pipeline.id} href={`/admin/rounds/pipeline/${pipeline.id}` as Route}>
|
||||||
|
<Card className="group cursor-pointer hover:shadow-md transition-shadow h-full flex flex-col">
|
||||||
<CardHeader className="pb-3">
|
<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 mb-1">
|
<CardTitle className="text-base leading-tight">
|
||||||
<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"
|
||||||
|
|
@ -174,7 +168,6 @@ 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}
|
||||||
|
|
@ -183,9 +176,8 @@ export default function PipelineListPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="mt-auto">
|
<CardContent className="mt-auto">
|
||||||
{/* Track Indicator - Simplified visualization */}
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<div className="mb-3 pb-3 border-b">
|
<div className="flex items-center gap-1.5">
|
||||||
<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
|
||||||
|
|
@ -195,52 +187,11 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useCallback } 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,
|
||||||
|
|
@ -13,143 +12,189 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Code } from 'lucide-react'
|
import {
|
||||||
|
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' },
|
{ value: 'competitionCategory', label: 'Competition Category', tooltip: 'Values: STARTUP, BUSINESS_CONCEPT' },
|
||||||
{ value: 'oceanIssue', label: 'Ocean Issue' },
|
{ value: 'oceanIssue', label: 'Ocean Issue', tooltip: 'The ocean issue the project addresses' },
|
||||||
{ value: 'country', label: 'Country' },
|
{ value: 'country', label: 'Country', tooltip: 'Country of origin' },
|
||||||
{ value: 'geographicZone', label: 'Geographic Zone' },
|
{ value: 'geographicZone', label: 'Geographic Zone', tooltip: 'Geographic zone of the project' },
|
||||||
{ value: 'wantsMentorship', label: 'Wants Mentorship' },
|
{ value: 'wantsMentorship', label: 'Wants Mentorship', tooltip: 'Boolean: true or false' },
|
||||||
{ value: 'tags', label: 'Tags' },
|
{ value: 'tags', label: 'Tags', tooltip: 'Project tags (comma-separated for "in" operator)' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const OPERATOR_OPTIONS = [
|
const OPERATOR_OPTIONS = [
|
||||||
{ value: 'equals', label: 'equals' },
|
{ value: 'eq', label: 'equals' },
|
||||||
{ value: 'not_equals', label: 'not equals' },
|
{ value: 'neq', label: 'does not equal' },
|
||||||
|
{ value: 'in', label: 'is one of' },
|
||||||
{ value: 'contains', label: 'contains' },
|
{ value: 'contains', label: 'contains' },
|
||||||
{ value: 'in', label: 'in' },
|
{ value: 'gt', label: 'greater than' },
|
||||||
|
{ value: 'lt', label: 'less than' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type SimplePredicate = {
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type SimpleCondition = {
|
||||||
field: string
|
field: string
|
||||||
operator: string
|
operator: string
|
||||||
value: string
|
value: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSimplePredicate(obj: Record<string, unknown>): obj is SimplePredicate {
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
return (
|
|
||||||
typeof obj.field === 'string' &&
|
function isSimpleCondition(obj: Record<string, unknown>): obj is SimpleCondition {
|
||||||
typeof obj.operator === 'string' &&
|
return typeof obj.field === 'string' && typeof obj.operator === 'string' && 'value' in obj
|
||||||
(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)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCompound(obj: Record<string, unknown>): boolean {
|
const addCondition = () => {
|
||||||
return 'or' in obj || 'and' in obj || 'not' in obj
|
const next = [...conditions, { field: 'competitionCategory', operator: 'eq', value: '' }]
|
||||||
|
setConditions(next)
|
||||||
|
emitChange(next, logic)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PredicateBuilder({ value, onChange }: PredicateBuilderProps) {
|
const removeCondition = (index: number) => {
|
||||||
const [jsonMode, setJsonMode] = useState(false)
|
if (conditions.length <= 1) return
|
||||||
const [jsonText, setJsonText] = useState('')
|
const next = conditions.filter((_, i) => i !== index)
|
||||||
|
setConditions(next)
|
||||||
const compound = isCompound(value)
|
emitChange(next, logic)
|
||||||
const simple = !compound && isSimplePredicate(value)
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const toggleLogic = () => {
|
||||||
if (compound) {
|
const nextLogic = logic === 'and' ? 'or' : 'and'
|
||||||
setJsonMode(true)
|
setLogic(nextLogic)
|
||||||
setJsonText(JSON.stringify(value, null, 2))
|
emitChange(conditions, nextLogic)
|
||||||
}
|
}
|
||||||
}, [compound, value])
|
|
||||||
|
|
||||||
if (jsonMode) {
|
|
||||||
return (
|
return (
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
{conditions.map((condition, index) => (
|
||||||
<div className="flex items-center gap-2">
|
<div key={index}>
|
||||||
<Label className="text-xs">Predicate (JSON)</Label>
|
{index > 0 && (
|
||||||
{compound && (
|
<div className="flex items-center gap-2 py-1">
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
<div className="h-px flex-1 bg-border" />
|
||||||
Complex condition
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!compound && (
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 text-xs"
|
className="h-5 px-2 text-[10px] font-medium"
|
||||||
onClick={() => {
|
onClick={toggleLogic}
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(jsonText) as Record<string, unknown>
|
|
||||||
onChange(parsed)
|
|
||||||
setJsonMode(false)
|
|
||||||
} catch {
|
|
||||||
// stay in JSON mode
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Switch to form
|
{logic.toUpperCase()}
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="h-px flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="flex items-center gap-1.5">
|
||||||
<Textarea
|
<Tooltip>
|
||||||
className="font-mono text-xs min-h-24"
|
<TooltipTrigger asChild>
|
||||||
value={jsonText}
|
<div className="w-[160px] shrink-0">
|
||||||
onChange={(e) => {
|
|
||||||
setJsonText(e.target.value)
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(e.target.value) as Record<string, unknown>
|
|
||||||
onChange(parsed)
|
|
||||||
} catch {
|
|
||||||
// don't update on invalid JSON
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const predicate: SimplePredicate = simple
|
|
||||||
? { field: value.field as string, operator: value.operator as string, value: String(value.value) }
|
|
||||||
: { field: 'competitionCategory', operator: 'equals', value: '' }
|
|
||||||
|
|
||||||
const updateField = (field: string, val: string) => {
|
|
||||||
const next = { ...predicate, [field]: val }
|
|
||||||
onChange(next as unknown as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<Label className="text-xs">Condition</Label>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 text-xs gap-1"
|
|
||||||
onClick={() => {
|
|
||||||
setJsonText(JSON.stringify(value, null, 2))
|
|
||||||
setJsonMode(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Code className="h-3 w-3" />
|
|
||||||
Edit as JSON
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-[10px] text-muted-foreground">Field</Label>
|
|
||||||
<Select
|
<Select
|
||||||
value={predicate.field}
|
value={condition.field}
|
||||||
onValueChange={(v) => updateField('field', v)}
|
onValueChange={(v) => updateCondition(index, 'field', v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -163,11 +208,16 @@ export function PredicateBuilder({ value, onChange }: PredicateBuilderProps) {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
</TooltipTrigger>
|
||||||
<Label className="text-[10px] text-muted-foreground">Operator</Label>
|
<TooltipContent side="top">
|
||||||
|
{FIELD_OPTIONS.find((f) => f.value === condition.field)?.tooltip || 'Select a field'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className="w-[130px] shrink-0">
|
||||||
<Select
|
<Select
|
||||||
value={predicate.operator}
|
value={condition.operator}
|
||||||
onValueChange={(v) => updateField('operator', v)}
|
onValueChange={(v) => updateCondition(index, 'operator', v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -181,16 +231,240 @@ export function PredicateBuilder({ value, onChange }: PredicateBuilderProps) {
|
||||||
</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"
|
className="h-8 text-xs flex-1 min-w-[100px]"
|
||||||
value={predicate.value}
|
value={displayValue(condition.value)}
|
||||||
onChange={(e) => updateField('value', e.target.value)}
|
onChange={(e) =>
|
||||||
placeholder="e.g. STARTUP"
|
updateCondition(index, 'value', parseInputValue(e.target.value, condition.field))
|
||||||
|
}
|
||||||
|
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,7 +4,6 @@ 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'
|
||||||
|
|
@ -15,17 +14,33 @@ 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
|
||||||
|
|
@ -57,16 +72,374 @@ type RuleDraft = {
|
||||||
|
|
||||||
const DEFAULT_PREDICATE = {
|
const DEFAULT_PREDICATE = {
|
||||||
field: 'competitionCategory',
|
field: 'competitionCategory',
|
||||||
operator: 'equals',
|
operator: 'eq',
|
||||||
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,
|
||||||
|
|
@ -95,13 +468,6 @@ 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]
|
||||||
|
|
@ -131,7 +497,7 @@ export function RoutingRulesEditor({
|
||||||
toast.error('Create a track before adding routing rules')
|
toast.error('Create a track before adding routing rules')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await upsertRule.mutateAsync({
|
const result = await upsertRule.mutateAsync({
|
||||||
pipelineId,
|
pipelineId,
|
||||||
name: `Routing Rule ${orderedRules.length + 1}`,
|
name: `Routing Rule ${orderedRules.length + 1}`,
|
||||||
scope: 'global',
|
scope: 'global',
|
||||||
|
|
@ -142,6 +508,10 @@ 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) => {
|
||||||
|
|
@ -162,291 +532,102 @@ export function RoutingRulesEditor({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMoveRule = async (index: number, direction: 'up' | 'down') => {
|
const handleUpdateDraft = (id: string, updates: Partial<RuleDraft>) => {
|
||||||
const targetIndex = direction === 'up' ? index - 1 : index + 1
|
setDrafts((prev) => ({
|
||||||
if (targetIndex < 0 || targetIndex >= orderedRules.length) return
|
...prev,
|
||||||
|
[id]: { ...prev[id], ...updates },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const reordered = [...orderedRules]
|
const handleToggleExpand = (id: string) => {
|
||||||
const temp = reordered[index]
|
setExpandedId((prev) => (prev === id ? null : id))
|
||||||
reordered[index] = reordered[targetIndex]
|
|
||||||
reordered[targetIndex] = temp
|
|
||||||
|
|
||||||
await reorderRules.mutateAsync({
|
|
||||||
pipelineId,
|
|
||||||
orderedIds: reordered.map((rule) => rule.id),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="space-y-3">
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm">Routing Rules</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
|
||||||
Loading routing rules...
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<CardTitle className="text-sm">Routing Rules</CardTitle>
|
|
||||||
<Button type="button" size="sm" onClick={handleCreateRule}>
|
|
||||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
Add Rule
|
|
||||||
</Button>
|
|
||||||
</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">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Route className="h-4 w-4 text-muted-foreground" />
|
||||||
type="button"
|
<h3 className="text-sm font-medium">Routing Rules</h3>
|
||||||
size="sm"
|
</div>
|
||||||
variant="outline"
|
<div className="flex items-center gap-2 text-sm text-muted-foreground p-4">
|
||||||
onClick={() =>
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
toggleRule.mutate({
|
Loading routing rules...
|
||||||
id: rule.id,
|
</div>
|
||||||
isActive: !draft.isActive,
|
</div>
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
disabled={toggleRule.isPending}
|
|
||||||
>
|
return (
|
||||||
{draft.isActive ? (
|
<div className="space-y-3">
|
||||||
<Power className="mr-1.5 h-3.5 w-3.5" />
|
{/* Section header */}
|
||||||
) : (
|
<TooltipProvider delayDuration={300}>
|
||||||
<PowerOff className="mr-1.5 h-3.5 w-3.5" />
|
<div className="flex items-center justify-between gap-2">
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
{draft.isActive ? 'Disable' : 'Enable'}
|
<Route className="h-4 w-4 text-muted-foreground" />
|
||||||
</Button>
|
<h3 className="text-sm font-medium">Routing Rules</h3>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<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>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
className="h-8"
|
||||||
onClick={() => handleSaveRule(rule.id)}
|
onClick={handleCreateRule}
|
||||||
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" />
|
||||||
) : (
|
) : (
|
||||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
Save
|
Add Rule
|
||||||
</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}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ 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
|
||||||
|
|
@ -143,6 +148,96 @@ 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,6 +2,7 @@
|
||||||
|
|
||||||
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,
|
||||||
|
|
@ -18,6 +19,11 @@ 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,
|
||||||
|
|
@ -27,9 +33,73 @@ 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>
|
||||||
|
|
@ -52,6 +122,7 @@ 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,11 +1,12 @@
|
||||||
'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 } from 'lucide-react'
|
import { Trophy, Users, ArrowUpDown, LayoutGrid } from 'lucide-react'
|
||||||
import type { SelectionConfig } from '@/types/pipeline-wizard'
|
import type { SelectionConfig } from '@/types/pipeline-wizard'
|
||||||
|
|
||||||
type SelectionPanelProps = {
|
type SelectionPanelProps = {
|
||||||
|
|
@ -13,6 +14,11 @@ 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
|
||||||
|
|
||||||
|
|
@ -29,7 +35,33 @@ 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 finalistTarget = config?.finalistCount ?? 6
|
const quotasEnabled = config?.categoryQuotasEnabled ?? false
|
||||||
|
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">
|
||||||
|
|
@ -41,8 +73,10 @@ 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">{finalistTarget}</p>
|
<p className="text-2xl font-bold mt-1">{finalistDisplay}</p>
|
||||||
<p className="text-xs text-muted-foreground">to be selected</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{quotasEnabled ? 'per-category quotas' : 'to be selected'}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -71,6 +105,65 @@ 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">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
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,6 +52,8 @@ 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,6 +689,12 @@ 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: {
|
||||||
|
|
@ -717,6 +723,7 @@ 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 } } },
|
||||||
},
|
},
|
||||||
|
|
@ -732,6 +739,28 @@ 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
|
||||||
|
|
@ -796,6 +825,34 @@ 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,6 +11,24 @@ 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
|
||||||
|
|
@ -719,7 +737,12 @@ export const filteringRouter = router({
|
||||||
* FILTERED_OUT → mark as REJECTED (data preserved)
|
* FILTERED_OUT → mark as REJECTED (data preserved)
|
||||||
*/
|
*/
|
||||||
finalizeResults: adminProcedure
|
finalizeResults: adminProcedure
|
||||||
.input(z.object({ stageId: z.string() }))
|
.input(
|
||||||
|
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 },
|
||||||
|
|
@ -737,15 +760,80 @@ 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)
|
||||||
|
|
||||||
const passedIds = results
|
let passedResults = 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>[] = []
|
||||||
|
|
||||||
|
|
@ -767,6 +855,21 @@ 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({
|
||||||
|
|
@ -778,6 +881,9 @@ 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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -785,6 +891,9 @@ 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,6 +3,8 @@ 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,
|
||||||
|
|
@ -380,4 +382,138 @@ 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,6 +723,7 @@ export const stageRouter = router({
|
||||||
status: true,
|
status: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
teamName: true,
|
teamName: true,
|
||||||
|
competitionCategory: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export interface ScoreBreakdown {
|
||||||
previousRoundFamiliarity: number
|
previousRoundFamiliarity: number
|
||||||
coiPenalty: number
|
coiPenalty: number
|
||||||
availabilityPenalty: number
|
availabilityPenalty: number
|
||||||
|
categoryQuotaPenalty: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssignmentScore {
|
export interface AssignmentScore {
|
||||||
|
|
@ -69,6 +70,8 @@ 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([
|
||||||
|
|
@ -267,6 +270,50 @@ 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 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -277,8 +324,9 @@ 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 } = options
|
const { stageId, type, limit = 50, aiMaxPerJudge = 20, categoryQuotas } = options
|
||||||
|
|
||||||
const projectStageStates = await prisma.projectStageState.findMany({
|
const projectStageStates = await prisma.projectStageState.findMany({
|
||||||
where: { stageId },
|
where: { stageId },
|
||||||
|
|
@ -297,6 +345,7 @@ 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 },
|
||||||
|
|
@ -372,6 +421,28 @@ 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 },
|
||||||
|
|
@ -485,6 +556,17 @@ 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()
|
||||||
|
|
@ -510,7 +592,8 @@ export async function getSmartSuggestions(options: {
|
||||||
countryScore +
|
countryScore +
|
||||||
geoDiversityPenalty +
|
geoDiversityPenalty +
|
||||||
previousRoundFamiliarity +
|
previousRoundFamiliarity +
|
||||||
availabilityPenalty
|
availabilityPenalty +
|
||||||
|
categoryQuotaPenalty
|
||||||
|
|
||||||
// Build reasoning
|
// Build reasoning
|
||||||
const reasoning: string[] = []
|
const reasoning: string[] = []
|
||||||
|
|
@ -540,6 +623,11 @@ 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,
|
||||||
|
|
@ -557,6 +645,7 @@ 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,
|
||||||
|
|
@ -690,6 +779,7 @@ export async function getMentorSuggestionsForProject(
|
||||||
previousRoundFamiliarity: 0,
|
previousRoundFamiliarity: 0,
|
||||||
coiPenalty: 0,
|
coiPenalty: 0,
|
||||||
availabilityPenalty: 0,
|
availabilityPenalty: 0,
|
||||||
|
categoryQuotaPenalty: 0,
|
||||||
},
|
},
|
||||||
reasoning,
|
reasoning,
|
||||||
matchingTags,
|
matchingTags,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ 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,12 +44,16 @@ 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