Pipeline UX: clickable cards, wizard edit, routing rules redesign, category quotas
All checks were successful
Build and Push Docker Image / build (push) Successful in 18s

- Simplify pipeline list cards: whole card is clickable, remove clutter
- Add wizard edit page for existing pipelines with full state pre-population
- Extract toWizardTrackConfig to shared utility for reuse
- Rewrite predicate builder with 3 modes: Simple (sentence-style), AI (NLP), Advanced (JSON)
- Fix routing operators to match backend (eq/neq/in/contains/gt/lt)
- Rewrite routing rules editor with collapsible cards and natural language summaries
- Add parseNaturalLanguageRule AI procedure for routing rules
- Add per-category quotas to SelectionConfig and EvaluationConfig
- Add category quota UI toggles to selection and assignment sections
- Add category breakdown display to selection panel
- Add category-aware scoring to smart assignment (penalty/bonus)
- Add category-aware filtering targets with excess demotion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-14 20:10:24 +01:00
parent c634982835
commit 382570cebd
17 changed files with 2577 additions and 1095 deletions

View File

@@ -1,11 +1,10 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useCallback } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Select,
SelectContent,
@@ -13,184 +12,459 @@ import {
SelectTrigger,
SelectValue,
} 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 = [
{ value: 'competitionCategory', label: 'Competition Category' },
{ value: 'oceanIssue', label: 'Ocean Issue' },
{ value: 'country', label: 'Country' },
{ value: 'geographicZone', label: 'Geographic Zone' },
{ value: 'wantsMentorship', label: 'Wants Mentorship' },
{ value: 'tags', label: 'Tags' },
{ value: 'competitionCategory', label: 'Competition Category', tooltip: 'Values: STARTUP, BUSINESS_CONCEPT' },
{ value: 'oceanIssue', label: 'Ocean Issue', tooltip: 'The ocean issue the project addresses' },
{ value: 'country', label: 'Country', tooltip: 'Country of origin' },
{ value: 'geographicZone', label: 'Geographic Zone', tooltip: 'Geographic zone of the project' },
{ value: 'wantsMentorship', label: 'Wants Mentorship', tooltip: 'Boolean: true or false' },
{ value: 'tags', label: 'Tags', tooltip: 'Project tags (comma-separated for "in" operator)' },
] as const
const OPERATOR_OPTIONS = [
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
{ value: 'eq', label: 'equals' },
{ value: 'neq', label: 'does not equal' },
{ value: 'in', label: 'is one of' },
{ value: 'contains', label: 'contains' },
{ value: 'in', label: 'in' },
{ value: 'gt', label: 'greater than' },
{ value: 'lt', label: 'less than' },
] as const
type SimplePredicate = {
// ─── Types ───────────────────────────────────────────────────────────────────
type SimpleCondition = {
field: string
operator: string
value: string
value: unknown
}
type CompoundPredicate = {
logic: 'and' | 'or'
conditions: SimpleCondition[]
}
type PredicateBuilderProps = {
value: Record<string, unknown>
onChange: (predicate: Record<string, unknown>) => void
pipelineId?: string
}
function isSimplePredicate(obj: Record<string, unknown>): obj is SimplePredicate {
return (
typeof obj.field === 'string' &&
typeof obj.operator === 'string' &&
(typeof obj.value === 'string' || typeof obj.value === 'boolean')
// ─── Helpers ─────────────────────────────────────────────────────────────────
function isSimpleCondition(obj: Record<string, unknown>): obj is SimpleCondition {
return typeof obj.field === 'string' && typeof obj.operator === 'string' && 'value' in obj
}
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]
)
}
function isCompound(obj: Record<string, unknown>): boolean {
return 'or' in obj || 'and' in obj || 'not' in obj
}
export function PredicateBuilder({ value, onChange }: PredicateBuilderProps) {
const [jsonMode, setJsonMode] = useState(false)
const [jsonText, setJsonText] = useState('')
const compound = isCompound(value)
const simple = !compound && isSimplePredicate(value)
useEffect(() => {
if (compound) {
setJsonMode(true)
setJsonText(JSON.stringify(value, null, 2))
}
}, [compound, value])
if (jsonMode) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Label className="text-xs">Predicate (JSON)</Label>
{compound && (
<Badge variant="secondary" className="text-[10px]">
Complex condition
</Badge>
)}
</div>
{!compound && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={() => {
try {
const parsed = JSON.parse(jsonText) as Record<string, unknown>
onChange(parsed)
setJsonMode(false)
} catch {
// stay in JSON mode
}
}}
>
Switch to form
</Button>
)}
</div>
<Textarea
className="font-mono text-xs min-h-24"
value={jsonText}
onChange={(e) => {
setJsonText(e.target.value)
try {
const parsed = JSON.parse(e.target.value) as Record<string, unknown>
onChange(parsed)
} catch {
// don't update on invalid JSON
}
}}
/>
</div>
)
const updateCondition = (index: number, field: keyof SimpleCondition, val: unknown) => {
const next = conditions.map((c, i) => (i === index ? { ...c, [field]: val } : c))
setConditions(next)
emitChange(next, logic)
}
const predicate: SimplePredicate = simple
? { field: value.field as string, operator: value.operator as string, value: String(value.value) }
: { field: 'competitionCategory', operator: 'equals', value: '' }
const addCondition = () => {
const next = [...conditions, { field: 'competitionCategory', operator: 'eq', value: '' }]
setConditions(next)
emitChange(next, logic)
}
const updateField = (field: string, val: string) => {
const next = { ...predicate, [field]: val }
onChange(next as unknown as Record<string, unknown>)
const removeCondition = (index: number) => {
if (conditions.length <= 1) return
const next = conditions.filter((_, i) => i !== index)
setConditions(next)
emitChange(next, logic)
}
const toggleLogic = () => {
const nextLogic = logic === 'and' ? 'or' : 'and'
setLogic(nextLogic)
emitChange(conditions, nextLogic)
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">Condition</Label>
<TooltipProvider delayDuration={300}>
<div className="space-y-2">
{conditions.map((condition, index) => (
<div key={index}>
{index > 0 && (
<div className="flex items-center gap-2 py-1">
<div className="h-px flex-1 bg-border" />
<Button
type="button"
variant="outline"
size="sm"
className="h-5 px-2 text-[10px] font-medium"
onClick={toggleLogic}
>
{logic.toUpperCase()}
</Button>
<div className="h-px flex-1 bg-border" />
</div>
)}
<div className="flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<div className="w-[160px] shrink-0">
<Select
value={condition.field}
onValueChange={(v) => updateCondition(index, 'field', v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</TooltipTrigger>
<TooltipContent side="top">
{FIELD_OPTIONS.find((f) => f.value === condition.field)?.tooltip || 'Select a field'}
</TooltipContent>
</Tooltip>
<div className="w-[130px] shrink-0">
<Select
value={condition.operator}
onValueChange={(v) => updateCondition(index, 'operator', v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATOR_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Input
className="h-8 text-xs flex-1 min-w-[100px]"
value={displayValue(condition.value)}
onChange={(e) =>
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>
))}
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-xs gap-1"
onClick={() => {
setJsonText(JSON.stringify(value, null, 2))
setJsonMode(true)
}}
className="h-7 text-xs"
onClick={addCondition}
>
<Code className="h-3 w-3" />
Edit as JSON
<Plus className="mr-1 h-3 w-3" />
Add condition
</Button>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">Field</Label>
<Select
value={predicate.field}
onValueChange={(v) => updateField('field', v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">Operator</Label>
<Select
value={predicate.operator}
onValueChange={(v) => updateField('operator', v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATOR_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">Value</Label>
<Input
className="h-8 text-xs"
value={predicate.value}
onChange={(e) => updateField('value', e.target.value)}
placeholder="e.g. STARTUP"
/>
</div>
</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>
)
}

View File

@@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { PredicateBuilder } from '@/components/admin/pipeline/predicate-builder'
@@ -15,17 +14,33 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Plus,
Save,
Trash2,
ArrowUp,
ArrowDown,
Loader2,
Power,
PowerOff,
ChevronDown,
ArrowRight,
HelpCircle,
Settings2,
Route,
} from 'lucide-react'
// ─── Types ───────────────────────────────────────────────────────────────────
type StageLite = {
id: string
name: string
@@ -57,16 +72,374 @@ type RuleDraft = {
const DEFAULT_PREDICATE = {
field: 'competitionCategory',
operator: 'equals',
operator: 'eq',
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({
pipelineId,
tracks,
}: RoutingRulesEditorProps) {
const utils = trpc.useUtils()
const [drafts, setDrafts] = useState<Record<string, RuleDraft>>({})
const [expandedId, setExpandedId] = useState<string | null>(null)
const { data: rules = [], isLoading } = trpc.routing.listRules.useQuery({
pipelineId,
@@ -95,13 +468,6 @@ export function RoutingRulesEditor({
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(
() => [...rules].sort((a, b) => b.priority - a.priority),
[rules]
@@ -131,7 +497,7 @@ export function RoutingRulesEditor({
toast.error('Create a track before adding routing rules')
return
}
await upsertRule.mutateAsync({
const result = await upsertRule.mutateAsync({
pipelineId,
name: `Routing Rule ${orderedRules.length + 1}`,
scope: 'global',
@@ -142,6 +508,10 @@ export function RoutingRulesEditor({
isActive: true,
predicateJson: DEFAULT_PREDICATE,
})
// Auto-expand the new rule
if (result?.id) {
setExpandedId(result.id)
}
}
const handleSaveRule = async (id: string) => {
@@ -162,291 +532,102 @@ export function RoutingRulesEditor({
})
}
const handleMoveRule = async (index: number, direction: 'up' | 'down') => {
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= orderedRules.length) return
const handleUpdateDraft = (id: string, updates: Partial<RuleDraft>) => {
setDrafts((prev) => ({
...prev,
[id]: { ...prev[id], ...updates },
}))
}
const reordered = [...orderedRules]
const temp = reordered[index]
reordered[index] = reordered[targetIndex]
reordered[targetIndex] = temp
await reorderRules.mutateAsync({
pipelineId,
orderedIds: reordered.map((rule) => rule.id),
})
const handleToggleExpand = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id))
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Routing Rules</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Route className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Routing Rules</h3>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground p-4">
<Loader2 className="h-4 w-4 animate-spin" />
Loading routing rules...
</CardContent>
</Card>
</div>
</div>
)
}
return (
<Card>
<CardHeader>
<div className="space-y-3">
{/* Section header */}
<TooltipProvider delayDuration={300}>
<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" />
<div className="flex items-center gap-2">
<Route className="h-4 w-4 text-muted-foreground" />
<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
type="button"
size="sm"
className="h-8"
onClick={handleCreateRule}
disabled={upsertRule.isPending}
>
{upsertRule.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<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.
</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 className="space-y-2">
{orderedRules.map((rule, index) => {
const draft = drafts[rule.id]
if (!draft) return null
{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 },
}))
}
return (
<RuleCard
key={rule.id}
draft={draft}
index={index}
tracks={tracks}
pipelineId={pipelineId}
expandedId={expandedId}
onToggleExpand={handleToggleExpand}
onUpdateDraft={handleUpdateDraft}
onSave={handleSaveRule}
onDelete={(id) => deleteRule.mutate({ id })}
onToggleActive={(id, isActive) => toggleRule.mutate({ id, isActive })}
isSaving={upsertRule.isPending}
isDeleting={deleteRule.isPending}
isToggling={toggleRule.isPending}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleMoveRule(index, 'up')}
disabled={index === 0 || reorderRules.isPending}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleMoveRule(index, 'down')}
disabled={
index === orderedRules.length - 1 || reorderRules.isPending
}
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() =>
toggleRule.mutate({
id: rule.id,
isActive: !draft.isActive,
})
}
disabled={toggleRule.isPending}
>
{draft.isActive ? (
<Power className="mr-1.5 h-3.5 w-3.5" />
) : (
<PowerOff className="mr-1.5 h-3.5 w-3.5" />
)}
{draft.isActive ? 'Disable' : 'Enable'}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => handleSaveRule(rule.id)}
disabled={upsertRule.isPending}
>
{upsertRule.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save
</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>
</div>
</div>
</div>
)
})}
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -13,6 +13,11 @@ import {
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { EvaluationConfig } from '@/types/pipeline-wizard'
const ASSIGNMENT_CATEGORIES = [
{ key: 'STARTUP', label: 'Startups' },
{ key: 'BUSINESS_CONCEPT', label: 'Business Concepts' },
] as const
type AssignmentSectionProps = {
config: EvaluationConfig
onChange: (config: EvaluationConfig) => void
@@ -143,6 +148,96 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
</SelectContent>
</Select>
</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>
)
}

View File

@@ -2,6 +2,7 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import {
Select,
@@ -18,6 +19,11 @@ type SelectionSectionProps = {
isActive?: boolean
}
const CATEGORIES = [
{ key: 'STARTUP', label: 'Startups' },
{ key: 'BUSINESS_CONCEPT', label: 'Business Concepts' },
] as const
export function SelectionSection({
config,
onChange,
@@ -27,31 +33,96 @@ export function SelectionSection({
onChange({ ...config, ...updates })
}
const quotas = config.categoryQuotas ?? {}
const quotaTotal = CATEGORIES.reduce((sum, c) => sum + (quotas[c.key] ?? 0), 0)
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Finalist Count</Label>
<InfoTooltip content="Optional fixed finalist target for this stage." />
<Label>Per-category quotas</Label>
<InfoTooltip content="Set separate finalist targets per competition category. When enabled, projects are selected independently within each category." />
</div>
<Input
type="number"
min={1}
max={500}
value={config.finalistCount ?? ''}
placeholder="e.g. 6"
disabled={isActive}
onChange={(e) =>
updateConfig({
finalistCount:
e.target.value.trim().length === 0
? undefined
: parseInt(e.target.value, 10) || undefined,
})
}
/>
<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">
{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="flex items-center gap-1.5">
<Label>Finalist Count</Label>
<InfoTooltip content="Optional fixed finalist target for this stage." />
</div>
<Input
type="number"
min={1}
max={500}
value={config.finalistCount ?? ''}
placeholder="e.g. 6"
disabled={isActive}
onChange={(e) =>
updateConfig({
finalistCount:
e.target.value.trim().length === 0
? undefined
: parseInt(e.target.value, 10) || undefined,
})
}
/>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-1.5">

View File

@@ -1,11 +1,12 @@
'use client'
import { useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
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'
type SelectionPanelProps = {
@@ -13,6 +14,11 @@ type SelectionPanelProps = {
configJson: Record<string, unknown> | null
}
const CATEGORY_LABELS: Record<string, string> = {
STARTUP: 'Startups',
BUSINESS_CONCEPT: 'Business Concepts',
}
export function SelectionPanel({ stageId, configJson }: SelectionPanelProps) {
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'
).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 (
<div className="space-y-4">
@@ -41,8 +73,10 @@ export function SelectionPanel({ stageId, configJson }: SelectionPanelProps) {
<Trophy className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Finalist Target</span>
</div>
<p className="text-2xl font-bold mt-1">{finalistTarget}</p>
<p className="text-xs text-muted-foreground">to be selected</p>
<p className="text-2xl font-bold mt-1">{finalistDisplay}</p>
<p className="text-xs text-muted-foreground">
{quotasEnabled ? 'per-category quotas' : 'to be selected'}
</p>
</CardContent>
</Card>
<Card>
@@ -71,6 +105,65 @@ export function SelectionPanel({ stageId, configJson }: SelectionPanelProps) {
</Card>
</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 */}
<Card>
<CardHeader className="pb-2">