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
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"> → </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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 · {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">
|
||||
|
||||
Reference in New Issue
Block a user