Simplify routing to award assignment, seed all CSV entries, fix category mapping
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m3s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m3s
- Remove RoutingRule model and routing engine (replaced by direct award assignment) - Simplify RoutingMode enum: PARALLEL/POST_MAIN → SHARED, keep EXCLUSIVE - Remove routing router, routing-rules-editor, and related tests - Update pipeline, award, and notification code to remove routing references - Seed: include all CSV entries (no filtering/dedup), AI screening handles duplicates - Seed: fix non-breaking space (U+00A0) bug in category/issue mapping - Stage filtering: add duplicate detection that flags projects for admin review Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,8 +18,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { Plus, X, Loader2, Sparkles, AlertCircle } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Plus, X, Sparkles, AlertCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// ─── Field & Operator Definitions ────────────────────────────────────────────
|
||||
@@ -289,22 +288,8 @@ function AIMode({
|
||||
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 })
|
||||
toast.error('AI rule parsing is not currently available')
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
@@ -322,19 +307,14 @@ function AIMode({
|
||||
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}
|
||||
disabled={!text.trim() || !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" />
|
||||
)}
|
||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||
Generate Rule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,633 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { PredicateBuilder } from '@/components/admin/pipeline/predicate-builder'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
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,
|
||||
Loader2,
|
||||
Power,
|
||||
PowerOff,
|
||||
ChevronDown,
|
||||
ArrowRight,
|
||||
HelpCircle,
|
||||
Settings2,
|
||||
Route,
|
||||
} from 'lucide-react'
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type StageLite = {
|
||||
id: string
|
||||
name: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
type TrackLite = {
|
||||
id: string
|
||||
name: string
|
||||
stages: StageLite[]
|
||||
}
|
||||
|
||||
type RoutingRulesEditorProps = {
|
||||
pipelineId: string
|
||||
tracks: TrackLite[]
|
||||
}
|
||||
|
||||
type RuleDraft = {
|
||||
id: string
|
||||
name: string
|
||||
scope: 'global' | 'track' | 'stage'
|
||||
sourceTrackId: string | null
|
||||
destinationTrackId: string
|
||||
destinationStageId: string | null
|
||||
priority: number
|
||||
isActive: boolean
|
||||
predicateJson: Record<string, unknown>
|
||||
}
|
||||
|
||||
const DEFAULT_PREDICATE = {
|
||||
field: 'competitionCategory',
|
||||
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"> → </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,
|
||||
})
|
||||
|
||||
const upsertRule = trpc.routing.upsertRule.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.routing.listRules.invalidate({ pipelineId })
|
||||
toast.success('Routing rule saved')
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const toggleRule = trpc.routing.toggleRule.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.routing.listRules.invalidate({ pipelineId })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const deleteRule = trpc.routing.deleteRule.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.routing.listRules.invalidate({ pipelineId })
|
||||
toast.success('Routing rule deleted')
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const orderedRules = useMemo(
|
||||
() => [...rules].sort((a, b) => b.priority - a.priority),
|
||||
[rules]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const nextDrafts: Record<string, RuleDraft> = {}
|
||||
for (const rule of orderedRules) {
|
||||
nextDrafts[rule.id] = {
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
scope: rule.scope as RuleDraft['scope'],
|
||||
sourceTrackId: rule.sourceTrackId ?? null,
|
||||
destinationTrackId: rule.destinationTrackId,
|
||||
destinationStageId: rule.destinationStageId ?? null,
|
||||
priority: rule.priority,
|
||||
isActive: rule.isActive,
|
||||
predicateJson: (rule.predicateJson as Record<string, unknown>) ?? {},
|
||||
}
|
||||
}
|
||||
setDrafts(nextDrafts)
|
||||
}, [orderedRules])
|
||||
|
||||
const handleCreateRule = async () => {
|
||||
const defaultTrack = tracks[0]
|
||||
if (!defaultTrack) {
|
||||
toast.error('Create a track before adding routing rules')
|
||||
return
|
||||
}
|
||||
const result = await upsertRule.mutateAsync({
|
||||
pipelineId,
|
||||
name: `Routing Rule ${orderedRules.length + 1}`,
|
||||
scope: 'global',
|
||||
sourceTrackId: null,
|
||||
destinationTrackId: defaultTrack.id,
|
||||
destinationStageId: defaultTrack.stages[0]?.id ?? null,
|
||||
priority: orderedRules.length + 1,
|
||||
isActive: true,
|
||||
predicateJson: DEFAULT_PREDICATE,
|
||||
})
|
||||
// Auto-expand the new rule
|
||||
if (result?.id) {
|
||||
setExpandedId(result.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveRule = async (id: string) => {
|
||||
const draft = drafts[id]
|
||||
if (!draft) return
|
||||
|
||||
await upsertRule.mutateAsync({
|
||||
id: draft.id,
|
||||
pipelineId,
|
||||
name: draft.name.trim(),
|
||||
scope: draft.scope,
|
||||
sourceTrackId: draft.sourceTrackId,
|
||||
destinationTrackId: draft.destinationTrackId,
|
||||
destinationStageId: draft.destinationStageId,
|
||||
priority: draft.priority,
|
||||
isActive: draft.isActive,
|
||||
predicateJson: draft.predicateJson,
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateDraft = (id: string, updates: Partial<RuleDraft>) => {
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[id]: { ...prev[id], ...updates },
|
||||
}))
|
||||
}
|
||||
|
||||
const handleToggleExpand = (id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Section header */}
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<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>
|
||||
</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
|
||||
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -149,10 +149,10 @@ export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs">Routing Mode</Label>
|
||||
<InfoTooltip content="Parallel: projects compete for all awards simultaneously. Exclusive: each project can only win one award. Post-main: awards are decided after the main track completes." />
|
||||
<InfoTooltip content="Shared: projects compete in the main track and this award simultaneously. Exclusive: projects are routed exclusively to this track." />
|
||||
</div>
|
||||
<Select
|
||||
value={track.routingModeDefault ?? 'PARALLEL'}
|
||||
value={track.routingModeDefault ?? 'SHARED'}
|
||||
onValueChange={(value) =>
|
||||
updateAward(index, {
|
||||
routingModeDefault: value as RoutingMode,
|
||||
@@ -164,15 +164,12 @@ export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PARALLEL">
|
||||
Parallel — Runs alongside main track
|
||||
<SelectItem value="SHARED">
|
||||
Shared — Projects compete in main + this award
|
||||
</SelectItem>
|
||||
<SelectItem value="EXCLUSIVE">
|
||||
Exclusive — Projects enter only this track
|
||||
</SelectItem>
|
||||
<SelectItem value="POST_MAIN">
|
||||
Post-Main — After main track completes
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -30,11 +30,6 @@ const NOTIFICATION_EVENTS = [
|
||||
label: 'Assignments Generated',
|
||||
description: 'When jury assignments are created or updated',
|
||||
},
|
||||
{
|
||||
key: 'routing.executed',
|
||||
label: 'Routing Executed',
|
||||
description: 'When projects are routed into tracks/stages',
|
||||
},
|
||||
{
|
||||
key: 'live.cursor.updated',
|
||||
label: 'Live Cursor Updated',
|
||||
|
||||
Reference in New Issue
Block a user