Compare commits

..

2 Commits

Author SHA1 Message Date
Matt 382570cebd Pipeline UX: clickable cards, wizard edit, routing rules redesign, category quotas
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>
2026-02-14 20:10:24 +01:00
Matt c634982835 Fix member selection checkboxes showing for all rows regardless of status
Previously checkboxes only appeared for users with status NONE (Not Invited),
hiding them for INVITED/ACTIVE members and making "Select all" confusing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 18:38:54 +01:00
18 changed files with 2665 additions and 1190 deletions

View File

@ -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}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -171,9 +171,8 @@ export function MembersContent() {
}, },
}) })
// Users on the current page that are selectable (status NONE)
const selectableUsers = useMemo( const selectableUsers = useMemo(
() => (data?.users ?? []).filter((u) => u.status === 'NONE'), () => data?.users ?? [],
[data?.users] [data?.users]
) )
@ -323,7 +322,7 @@ export function MembersContent() {
<Checkbox <Checkbox
checked={allSelectableSelected ? true : someSelectableSelected ? 'indeterminate' : false} checked={allSelectableSelected ? true : someSelectableSelected ? 'indeterminate' : false}
onCheckedChange={toggleAll} onCheckedChange={toggleAll}
aria-label="Select all uninvited members" aria-label="Select all members"
/> />
)} )}
</TableHead> </TableHead>
@ -340,15 +339,11 @@ export function MembersContent() {
{data.users.map((user) => ( {data.users.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell> <TableCell>
{user.status === 'NONE' ? (
<Checkbox <Checkbox
checked={selectedIds.has(user.id)} checked={selectedIds.has(user.id)}
onCheckedChange={() => toggleUser(user.id)} onCheckedChange={() => toggleUser(user.id)}
aria-label={`Select ${user.name || user.email}`} aria-label={`Select ${user.name || user.email}`}
/> />
) : (
<span />
)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -438,14 +433,12 @@ export function MembersContent() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{user.status === 'NONE' && (
<Checkbox <Checkbox
checked={selectedIds.has(user.id)} checked={selectedIds.has(user.id)}
onCheckedChange={() => toggleUser(user.id)} onCheckedChange={() => toggleUser(user.id)}
aria-label={`Select ${user.name || user.email}`} aria-label={`Select ${user.name || user.email}`}
className="mt-1" className="mt-1"
/> />
)}
<UserAvatar <UserAvatar
user={user} user={user}
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined} avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}

View File

@ -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>
) )
} }

View File

@ -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"> &rarr; </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>
) )
} }

View File

@ -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>
) )
} }

View File

@ -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">

View File

@ -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 &middot; {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">

View File

@ -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,
}
}

View File

@ -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 },
} }
} }

View File

@ -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',

View File

@ -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,
} }

View File

@ -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',
}
}),
}) })

View File

@ -723,6 +723,7 @@ export const stageRouter = router({
status: true, status: true,
tags: true, tags: true,
teamName: true, teamName: true,
competitionCategory: true,
}, },
}, },
}, },

View File

@ -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,

View File

@ -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'

View File

@ -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 = {