Fix AI filtering bugs, add special award shortlist integration
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m20s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m20s
Part 1 - Bug Fixes: - Fix toProjectWithRelations() stripping file fields needed by AI (detectedLang, textContent, etc.) - Fix parseAIData() reading flat when aiScreeningJson is nested under rule ID - Fix getAIConfidenceScore() with same nesting issue (always returned 0) Part 2 - Special Award Track Integration: - Add shortlistSize to SpecialAward, qualityScore/shortlisted/confirmed fields to AwardEligibility - Add specialAwardId to Round for award-owned rounds - Update AI eligibility service to return qualityScore (0-100) for ranking - Update eligibility job with filteringRoundId scoping and auto-shortlist top N - Add 8 new specialAward router procedures (listForRound, runEligibilityForRound, listShortlist, toggleShortlisted, confirmShortlist, listRounds, createRound, deleteRound) - Create award-shortlist.tsx component with ranked table, shortlist checkboxes, confirm dialog - Add "Special Award Tracks" section to filtering dashboard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
323
src/components/admin/round/award-shortlist.tsx
Normal file
323
src/components/admin/round/award-shortlist.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Award,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
Play,
|
||||
Star,
|
||||
Trophy,
|
||||
} from 'lucide-react'
|
||||
|
||||
type AwardShortlistProps = {
|
||||
awardId: string
|
||||
roundId: string
|
||||
awardName: string
|
||||
criteriaText?: string | null
|
||||
eligibilityMode: string
|
||||
shortlistSize: number
|
||||
jobStatus?: string | null
|
||||
jobTotal?: number | null
|
||||
jobDone?: number | null
|
||||
}
|
||||
|
||||
export function AwardShortlist({
|
||||
awardId,
|
||||
roundId,
|
||||
awardName,
|
||||
criteriaText,
|
||||
eligibilityMode,
|
||||
shortlistSize,
|
||||
jobStatus,
|
||||
jobTotal,
|
||||
jobDone,
|
||||
}: AwardShortlistProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING'
|
||||
|
||||
const { data: shortlist, isLoading: isLoadingShortlist } = trpc.specialAward.listShortlist.useQuery(
|
||||
{ awardId, perPage: 100 },
|
||||
{ enabled: expanded && !isRunning }
|
||||
)
|
||||
|
||||
const { data: jobPoll } = trpc.specialAward.getEligibilityJobStatus.useQuery(
|
||||
{ awardId },
|
||||
{ enabled: isRunning, refetchInterval: 3000 }
|
||||
)
|
||||
|
||||
const runMutation = trpc.specialAward.runEligibilityForRound.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Eligibility evaluation started')
|
||||
utils.specialAward.getEligibilityJobStatus.invalidate({ awardId })
|
||||
utils.specialAward.listForRound.invalidate({ roundId })
|
||||
},
|
||||
onError: (err) => toast.error(`Failed: ${err.message}`),
|
||||
})
|
||||
|
||||
const toggleMutation = trpc.specialAward.toggleShortlisted.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.specialAward.listShortlist.invalidate({ awardId })
|
||||
utils.specialAward.listForRound.invalidate({ roundId })
|
||||
toast.success(data.shortlisted ? 'Added to shortlist' : 'Removed from shortlist')
|
||||
},
|
||||
onError: (err) => toast.error(`Failed: ${err.message}`),
|
||||
})
|
||||
|
||||
const confirmMutation = trpc.specialAward.confirmShortlist.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.specialAward.listShortlist.invalidate({ awardId })
|
||||
utils.specialAward.listForRound.invalidate({ roundId })
|
||||
toast.success(
|
||||
`Confirmed ${data.confirmedCount} projects` +
|
||||
(data.routedCount > 0 ? ` — ${data.routedCount} routed to award track` : '')
|
||||
)
|
||||
},
|
||||
onError: (err) => toast.error(`Failed: ${err.message}`),
|
||||
})
|
||||
|
||||
const currentJobStatus = jobPoll?.eligibilityJobStatus ?? jobStatus
|
||||
const currentJobDone = jobPoll?.eligibilityJobDone ?? jobDone
|
||||
const currentJobTotal = jobPoll?.eligibilityJobTotal ?? jobTotal
|
||||
const jobProgress = currentJobTotal && currentJobTotal > 0
|
||||
? Math.round(((currentJobDone ?? 0) / currentJobTotal) * 100)
|
||||
: 0
|
||||
|
||||
const shortlistedCount = shortlist?.eligibilities?.filter((e) => e.shortlisted).length ?? 0
|
||||
|
||||
return (
|
||||
<Collapsible open={expanded} onOpenChange={setExpanded}>
|
||||
<div className="border rounded-lg">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<Trophy className="h-5 w-5 text-amber-600" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm">{awardName}</h4>
|
||||
{criteriaText && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1 max-w-md">
|
||||
{criteriaText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className={eligibilityMode === 'SEPARATE_POOL'
|
||||
? 'bg-purple-50 text-purple-700 border-purple-200'
|
||||
: 'bg-blue-50 text-blue-700 border-blue-200'
|
||||
}>
|
||||
{eligibilityMode === 'SEPARATE_POOL' ? 'Separate Pool' : 'Main Pool'}
|
||||
</Badge>
|
||||
{currentJobStatus === 'COMPLETED' && (
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Evaluated
|
||||
</Badge>
|
||||
)}
|
||||
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="border-t p-4 space-y-4">
|
||||
{/* Job controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Evaluate PASSED projects against this award's criteria
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => runMutation.mutate({ awardId, roundId })}
|
||||
disabled={runMutation.isPending || isRunning}
|
||||
>
|
||||
{isRunning ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Processing...</>
|
||||
) : runMutation.isPending ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Starting...</>
|
||||
) : currentJobStatus === 'COMPLETED' ? (
|
||||
<><Play className="h-4 w-4 mr-2" />Re-evaluate</>
|
||||
) : (
|
||||
<><Play className="h-4 w-4 mr-2" />Run Eligibility</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{isRunning && currentJobTotal && currentJobTotal > 0 && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={jobProgress} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{currentJobDone ?? 0} / {currentJobTotal} projects
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shortlist table */}
|
||||
{expanded && currentJobStatus === 'COMPLETED' && (
|
||||
<>
|
||||
{isLoadingShortlist ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||
</div>
|
||||
) : shortlist && shortlist.eligibilities.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">
|
||||
{shortlist.total} eligible projects
|
||||
{shortlistedCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({shortlistedCount} shortlisted)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{shortlistedCount > 0 && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="default">
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
Confirm Shortlist ({shortlistedCount})
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Shortlist</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{eligibilityMode === 'SEPARATE_POOL'
|
||||
? `This will confirm ${shortlistedCount} projects for the "${awardName}" award track. Projects will be routed to the award's rounds for separate evaluation.`
|
||||
: `This will confirm ${shortlistedCount} projects as eligible for the "${awardName}" award. Projects remain in the main competition pool.`
|
||||
}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => confirmMutation.mutate({ awardId })}
|
||||
disabled={confirmMutation.isPending}
|
||||
>
|
||||
{confirmMutation.isPending ? 'Confirming...' : 'Confirm'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left w-8">#</th>
|
||||
<th className="px-3 py-2 text-left">Project</th>
|
||||
<th className="px-3 py-2 text-left w-24">Score</th>
|
||||
<th className="px-3 py-2 text-left w-32">Reasoning</th>
|
||||
<th className="px-3 py-2 text-center w-20">Shortlist</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shortlist.eligibilities.map((e, i) => {
|
||||
const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined
|
||||
return (
|
||||
<tr key={e.id} className={`border-t ${e.shortlisted ? 'bg-amber-50/50' : ''}`}>
|
||||
<td className="px-3 py-2 text-muted-foreground font-mono">
|
||||
{i + 1}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div>
|
||||
<p className="font-medium">{e.project.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{e.project.teamName || e.project.country || e.project.competitionCategory || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={e.qualityScore ?? 0}
|
||||
className="h-2 w-16"
|
||||
/>
|
||||
<span className="text-xs font-mono font-medium">
|
||||
{e.qualityScore ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{reasoning ? (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2" title={reasoning}>
|
||||
{reasoning}
|
||||
</p>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Checkbox
|
||||
checked={e.shortlisted}
|
||||
onCheckedChange={() =>
|
||||
toggleMutation.mutate({ awardId, projectId: e.project.id })
|
||||
}
|
||||
disabled={toggleMutation.isPending}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No eligible projects found
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Not yet evaluated */}
|
||||
{expanded && !currentJobStatus && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Click "Run Eligibility" to evaluate projects against this award's criteria
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Failed */}
|
||||
{currentJobStatus === 'FAILED' && (
|
||||
<p className="text-sm text-red-600 text-center py-2">
|
||||
Eligibility evaluation failed. Try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -45,11 +45,11 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Award,
|
||||
Play,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -68,10 +68,11 @@ import {
|
||||
FileText,
|
||||
Brain,
|
||||
ListFilter,
|
||||
GripVertical,
|
||||
Settings2,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { AwardShortlist } from './award-shortlist'
|
||||
|
||||
type FilteringDashboardProps = {
|
||||
competitionId: string
|
||||
@@ -103,17 +104,29 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// AI criteria state
|
||||
const [criteriaText, setCriteriaText] = useState('')
|
||||
const [aiAction, setAiAction] = useState<'PASS' | 'REJECT' | 'FLAG'>('FLAG')
|
||||
const [batchSize, setBatchSize] = useState(20)
|
||||
const [parallelBatches, setParallelBatches] = useState(3)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [criteriaDirty, setCriteriaDirty] = useState(false)
|
||||
const criteriaLoaded = useRef(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// -- Queries --
|
||||
const { data: stats, isLoading: statsLoading } = trpc.filtering.getResultStats.useQuery(
|
||||
const { data: latestJob } = trpc.filtering.getLatestJob.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 15_000 },
|
||||
{ refetchInterval: pollingJobId ? 3_000 : 15_000 },
|
||||
)
|
||||
|
||||
const { data: latestJob, isLoading: jobLoading } = trpc.filtering.getLatestJob.useQuery(
|
||||
// Dynamic refetch: 3s during running job, 15s otherwise
|
||||
const isRunning = !!pollingJobId || latestJob?.status === 'RUNNING'
|
||||
|
||||
const { data: stats, isLoading: statsLoading } = trpc.filtering.getResultStats.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 15_000 },
|
||||
{ refetchInterval: isRunning ? 3_000 : 15_000 },
|
||||
)
|
||||
|
||||
const { data: rules } = trpc.filtering.getRules.useQuery(
|
||||
@@ -128,7 +141,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
page,
|
||||
perPage: 25,
|
||||
},
|
||||
{ refetchInterval: 15_000 },
|
||||
{ refetchInterval: isRunning ? 3_000 : 15_000 },
|
||||
)
|
||||
|
||||
const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery(
|
||||
@@ -139,6 +152,19 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
},
|
||||
)
|
||||
|
||||
// Load AI criteria from existing rule
|
||||
const aiRule = rules?.find((r: any) => r.ruleType === 'AI_SCREENING')
|
||||
useEffect(() => {
|
||||
if (aiRule && !criteriaLoaded.current) {
|
||||
const config = (aiRule.configJson || {}) as Record<string, unknown>
|
||||
setCriteriaText((config.criteriaText as string) || '')
|
||||
setAiAction((config.action as 'PASS' | 'REJECT' | 'FLAG') || 'FLAG')
|
||||
setBatchSize((config.batchSize as number) || 20)
|
||||
setParallelBatches((config.parallelBatches as number) || 3)
|
||||
criteriaLoaded.current = true
|
||||
}
|
||||
}, [aiRule])
|
||||
|
||||
// Stop polling when job completes
|
||||
useEffect(() => {
|
||||
if (jobStatus && (jobStatus.status === 'COMPLETED' || jobStatus.status === 'FAILED')) {
|
||||
@@ -162,6 +188,16 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
}, [latestJob])
|
||||
|
||||
// -- Mutations --
|
||||
const createRuleMutation = trpc.filtering.createRule.useMutation({
|
||||
onSuccess: () => utils.filtering.getRules.invalidate({ roundId }),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateRuleMutation = trpc.filtering.updateRule.useMutation({
|
||||
onSuccess: () => utils.filtering.getRules.invalidate({ roundId }),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const startJobMutation = trpc.filtering.startJob.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setPollingJobId(data.jobId)
|
||||
@@ -212,8 +248,59 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
})
|
||||
|
||||
// -- Handlers --
|
||||
const handleStartJob = () => {
|
||||
startJobMutation.mutate({ roundId })
|
||||
const handleSaveAndRun = async () => {
|
||||
if (!criteriaText.trim()) {
|
||||
toast.error('Please write screening criteria first')
|
||||
return
|
||||
}
|
||||
|
||||
const configJson = {
|
||||
criteriaText,
|
||||
action: aiAction,
|
||||
batchSize,
|
||||
parallelBatches,
|
||||
}
|
||||
|
||||
try {
|
||||
if (aiRule) {
|
||||
await updateRuleMutation.mutateAsync({ id: (aiRule as any).id, configJson })
|
||||
} else {
|
||||
await createRuleMutation.mutateAsync({
|
||||
roundId,
|
||||
name: 'AI Screening',
|
||||
ruleType: 'AI_SCREENING',
|
||||
configJson,
|
||||
priority: 0,
|
||||
})
|
||||
}
|
||||
setCriteriaDirty(false)
|
||||
startJobMutation.mutate({ roundId })
|
||||
} catch {
|
||||
// Error handled by mutation onError
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveCriteria = async () => {
|
||||
const configJson = {
|
||||
criteriaText,
|
||||
action: aiAction,
|
||||
batchSize,
|
||||
parallelBatches,
|
||||
}
|
||||
|
||||
if (aiRule) {
|
||||
await updateRuleMutation.mutateAsync({ id: (aiRule as any).id, configJson })
|
||||
} else {
|
||||
await createRuleMutation.mutateAsync({
|
||||
roundId,
|
||||
name: 'AI Screening',
|
||||
ruleType: 'AI_SCREENING',
|
||||
configJson,
|
||||
priority: 0,
|
||||
})
|
||||
}
|
||||
setCriteriaDirty(false)
|
||||
toast.success('Criteria saved')
|
||||
}
|
||||
|
||||
const handleOverride = () => {
|
||||
@@ -275,14 +362,25 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
|
||||
const parseAIData = (json: unknown): AIScreeningData | null => {
|
||||
if (!json || typeof json !== 'object') return null
|
||||
return json as AIScreeningData
|
||||
const obj = json as Record<string, unknown>
|
||||
// aiScreeningJson is nested under rule ID: { [ruleId]: { outcome, confidence, ... } }
|
||||
// Unwrap first entry if top-level keys don't include expected AI fields
|
||||
if (!('outcome' in obj)) {
|
||||
const keys = Object.keys(obj)
|
||||
if (keys.length > 0) {
|
||||
const inner = obj[keys[0]]
|
||||
if (inner && typeof inner === 'object') return inner as AIScreeningData
|
||||
}
|
||||
return null
|
||||
}
|
||||
return obj as unknown as AIScreeningData
|
||||
}
|
||||
|
||||
// Is there a running job?
|
||||
const isRunning = !!pollingJobId || latestJob?.status === 'RUNNING'
|
||||
const activeJob = jobStatus || (latestJob?.status === 'RUNNING' ? latestJob : null)
|
||||
const hasResults = stats && stats.total > 0
|
||||
const hasRules = rules && rules.length > 0
|
||||
const nonAiRules = rules?.filter((r: any) => r.ruleType !== 'AI_SCREENING') ?? []
|
||||
const hasNonAiRules = nonAiRules.length > 0
|
||||
const isSaving = createRuleMutation.isPending || updateRuleMutation.isPending
|
||||
|
||||
// Filter results by search query (client-side)
|
||||
const displayResults = resultsPage?.results.filter((r: any) => {
|
||||
@@ -296,35 +394,48 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Job Control */}
|
||||
{/* Main Card: AI Screening Criteria + Controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">AI Filtering</CardTitle>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Brain className="h-5 w-5 text-purple-600" />
|
||||
AI Screening
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Run AI screening against {hasRules ? rules.length : 0} active rule{rules?.length !== 1 ? 's' : ''}
|
||||
Write all your screening criteria below — the AI evaluates each project against them
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{criteriaDirty && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSaveCriteria}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleStartJob}
|
||||
disabled={isRunning || startJobMutation.isPending || !hasRules}
|
||||
size="sm"
|
||||
onClick={handleSaveAndRun}
|
||||
disabled={isRunning || startJobMutation.isPending || !criteriaText.trim()}
|
||||
>
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Running...</>
|
||||
) : (
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
<><Play className="h-4 w-4 mr-2" />Run Filtering</>
|
||||
)}
|
||||
{isRunning ? 'Running...' : 'Run Filtering'}
|
||||
</Button>
|
||||
{hasResults && (
|
||||
{hasResults && !isRunning && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={isRunning || finalizeMutation.isPending}>
|
||||
<Button variant="outline" size="sm" disabled={finalizeMutation.isPending}>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Finalize Results
|
||||
Finalize
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
@@ -353,16 +464,76 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
placeholder={`Write all your screening criteria here. The AI will evaluate each project against these requirements.\n\nExample:\n1. Project must have clear ocean conservation impact\n2. Documents should be in English or French\n3. For Business Concepts, academic rigor is acceptable\n4. For African projects, apply a lower quality threshold (score >= 5/10)\n5. Flag any submissions that appear to be AI-generated filler`}
|
||||
value={criteriaText}
|
||||
onChange={(e) => { setCriteriaText(e.target.value); setCriteriaDirty(true) }}
|
||||
rows={8}
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Available data per project: category, country, region, founded year, ocean issue, tags,
|
||||
description, file details (type, pages, size, detected language), and team size.
|
||||
</p>
|
||||
|
||||
{/* Job Progress */}
|
||||
{isRunning && activeJob && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
{/* Advanced Settings */}
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Advanced Settings
|
||||
{advancedOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3">
|
||||
<div className="grid grid-cols-3 gap-3 rounded-lg border p-3 bg-muted/20">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Default Action</Label>
|
||||
<Select value={aiAction} onValueChange={(v) => { setAiAction(v as 'PASS' | 'REJECT' | 'FLAG'); setCriteriaDirty(true) }}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="FLAG">Flag for review</SelectItem>
|
||||
<SelectItem value="REJECT">Auto-reject failures</SelectItem>
|
||||
<SelectItem value="PASS">Auto-pass matches</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Batch Size</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
className="h-8 text-xs"
|
||||
value={batchSize}
|
||||
onChange={(e) => { setBatchSize(parseInt(e.target.value) || 20); setCriteriaDirty(true) }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Parallel Batches</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
className="h-8 text-xs"
|
||||
value={parallelBatches}
|
||||
onChange={(e) => { setParallelBatches(parseInt(e.target.value) || 3); setCriteriaDirty(true) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Job Progress */}
|
||||
{isRunning && activeJob && (
|
||||
<div className="space-y-2 pt-3 border-t">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
<span className="text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Processing batch {activeJob.currentBatch} of {activeJob.totalBatches || '?'}
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
<span className="font-mono text-sm">
|
||||
{activeJob.processedCount}/{activeJob.totalProjects}
|
||||
</span>
|
||||
</div>
|
||||
@@ -372,43 +543,34 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Results appear in the table below as each batch completes
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Last job summary */}
|
||||
{!isRunning && latestJob && latestJob.status === 'COMPLETED' && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{/* Last job summary */}
|
||||
{!isRunning && latestJob && latestJob.status === 'COMPLETED' && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground pt-3 border-t">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
Last run completed: {latestJob.passedCount} passed, {latestJob.filteredCount} filtered, {latestJob.flaggedCount} flagged
|
||||
Last run: {latestJob.passedCount} passed, {latestJob.filteredCount} filtered, {latestJob.flaggedCount} flagged
|
||||
<span className="text-xs">
|
||||
({new Date(latestJob.completedAt!).toLocaleDateString()})
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
)}
|
||||
|
||||
{!isRunning && latestJob && latestJob.status === 'FAILED' && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center gap-2 text-sm text-red-600">
|
||||
{!isRunning && latestJob && latestJob.status === 'FAILED' && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-600 pt-3 border-t">
|
||||
<XCircle className="h-4 w-4" />
|
||||
Last run failed: {latestJob.errorMessage || 'Unknown error'}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{!hasRules && (
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm text-amber-600">
|
||||
No active filtering rules configured. Add rules in the Configuration tab first.
|
||||
</p>
|
||||
</CardContent>
|
||||
)}
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filtering Rules */}
|
||||
<FilteringRulesSection roundId={roundId} />
|
||||
{/* Additional Rules (Field-Based & Document Checks) */}
|
||||
<AdditionalRulesSection roundId={roundId} hasNonAiRules={hasNonAiRules} />
|
||||
|
||||
{/* Stats Cards */}
|
||||
{statsLoading ? (
|
||||
@@ -477,7 +639,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
) : null}
|
||||
|
||||
{/* Results Table */}
|
||||
{(hasResults || resultsLoading) && (
|
||||
{(hasResults || resultsLoading || isRunning) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
@@ -485,6 +647,11 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
<CardTitle className="text-base">Filtering Results</CardTitle>
|
||||
<CardDescription>
|
||||
Review AI screening outcomes — click a row to see reasoning, use quick buttons to override
|
||||
{isRunning && (
|
||||
<span className="ml-2 text-purple-600 font-medium animate-pulse">
|
||||
New results streaming in...
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -573,7 +740,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
const isExpanded = expandedId === result.id
|
||||
|
||||
return (
|
||||
<div key={result.id} className="border-b last:border-b-0">
|
||||
<div key={result.id} className="border-b last:border-b-0 animate-in fade-in-0 duration-300">
|
||||
{/* Main Row */}
|
||||
<div
|
||||
className="grid grid-cols-[40px_1fr_120px_100px_70px_70px_120px] gap-2 px-3 py-2.5 items-center hover:bg-muted/50 text-sm cursor-pointer"
|
||||
@@ -816,10 +983,10 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Sparkles className="h-8 w-8 text-muted-foreground mb-3" />
|
||||
<p className="text-sm font-medium">
|
||||
{searchQuery.trim() ? 'No results match your search' : 'No results yet'}
|
||||
{searchQuery.trim() ? 'No results match your search' : isRunning ? 'Results will appear here as batches complete' : 'No results yet'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{searchQuery.trim() ? 'Try a different search term' : 'Run the filtering job to screen projects'}
|
||||
{searchQuery.trim() ? 'Try a different search term' : isRunning ? 'The AI is processing projects...' : 'Run filtering to screen projects'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -827,6 +994,9 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Special Award Tracks */}
|
||||
{hasResults && <AwardTracksSection competitionId={competitionId} roundId={roundId} />}
|
||||
|
||||
{/* Single Override Dialog (with reason) */}
|
||||
<Dialog open={overrideDialogOpen} onOpenChange={setOverrideDialogOpen}>
|
||||
<DialogContent>
|
||||
@@ -948,14 +1118,13 @@ function ConfidenceIndicator({ value }: { value: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Filtering Rules Section ────────────────────────────────────────────────
|
||||
// ─── Additional Rules Section (Field-Based & Document Checks only) ──────────
|
||||
|
||||
type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING'
|
||||
type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK'
|
||||
|
||||
const RULE_TYPE_META: Record<RuleType, { label: string; icon: typeof ListFilter; color: string; description: string }> = {
|
||||
FIELD_BASED: { label: 'Field-Based', icon: ListFilter, color: 'bg-blue-100 text-blue-800 border-blue-200', description: 'Evaluate project fields (category, founding date, location, etc.)' },
|
||||
DOCUMENT_CHECK: { label: 'Document Check', icon: FileText, color: 'bg-teal-100 text-teal-800 border-teal-200', description: 'Validate file uploads (min count, formats, page limits)' },
|
||||
AI_SCREENING: { label: 'AI Screening', icon: Brain, color: 'bg-purple-100 text-purple-800 border-purple-200', description: 'GPT evaluates projects against natural language criteria' },
|
||||
}
|
||||
|
||||
const FIELD_OPTIONS = [
|
||||
@@ -997,11 +1166,6 @@ type RuleFormData = {
|
||||
maxPages: number | ''
|
||||
maxPagesByFileType: Record<string, number>
|
||||
docAction: 'PASS' | 'REJECT' | 'FLAG'
|
||||
// AI_SCREENING
|
||||
criteriaText: string
|
||||
aiAction: 'PASS' | 'REJECT' | 'FLAG'
|
||||
batchSize: number
|
||||
parallelBatches: number
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: RuleFormData = {
|
||||
@@ -1016,10 +1180,6 @@ const DEFAULT_FORM: RuleFormData = {
|
||||
maxPages: '',
|
||||
maxPagesByFileType: {},
|
||||
docAction: 'REJECT',
|
||||
criteriaText: '',
|
||||
aiAction: 'FLAG',
|
||||
batchSize: 20,
|
||||
parallelBatches: 1,
|
||||
}
|
||||
|
||||
function buildConfigJson(form: RuleFormData): Record<string, unknown> {
|
||||
@@ -1044,13 +1204,6 @@ function buildConfigJson(form: RuleFormData): Record<string, unknown> {
|
||||
if (Object.keys(form.maxPagesByFileType).length > 0) config.maxPagesByFileType = form.maxPagesByFileType
|
||||
return config
|
||||
}
|
||||
case 'AI_SCREENING':
|
||||
return {
|
||||
criteriaText: form.criteriaText,
|
||||
action: form.aiAction,
|
||||
batchSize: form.batchSize,
|
||||
parallelBatches: form.parallelBatches,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1075,21 +1228,13 @@ function parseConfigToForm(rule: { name: string; ruleType: string; configJson: u
|
||||
maxPagesByFileType: (config.maxPagesByFileType as Record<string, number>) || {},
|
||||
docAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'REJECT',
|
||||
}
|
||||
case 'AI_SCREENING':
|
||||
return {
|
||||
...base,
|
||||
criteriaText: (config.criteriaText as string) || '',
|
||||
aiAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'FLAG',
|
||||
batchSize: (config.batchSize as number) || 20,
|
||||
parallelBatches: (config.parallelBatches as number) || 1,
|
||||
}
|
||||
default:
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
function AdditionalRulesSection({ roundId, hasNonAiRules }: { roundId: string; hasNonAiRules: boolean }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingRule, setEditingRule] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<RuleFormData>({ ...DEFAULT_FORM })
|
||||
@@ -1097,11 +1242,14 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: rules, isLoading } = trpc.filtering.getRules.useQuery(
|
||||
const { data: allRules, isLoading } = trpc.filtering.getRules.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
// Only show non-AI rules
|
||||
const rules = allRules?.filter((r: any) => r.ruleType !== 'AI_SCREENING') ?? []
|
||||
|
||||
const createMutation = trpc.filtering.createRule.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.filtering.getRules.invalidate({ roundId })
|
||||
@@ -1156,12 +1304,10 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingRule(null)
|
||||
setForm({ ...DEFAULT_FORM, priority: (rules?.length ?? 0) })
|
||||
setForm({ ...DEFAULT_FORM, priority: rules.length })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const meta = RULE_TYPE_META[form.ruleType]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
@@ -1172,9 +1318,9 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<ListFilter className="h-5 w-5 text-[#053d57]" />
|
||||
<div>
|
||||
<CardTitle className="text-base">Filtering Rules</CardTitle>
|
||||
<CardTitle className="text-base">Additional Rules</CardTitle>
|
||||
<CardDescription>
|
||||
{rules?.length ?? 0} active rule{(rules?.length ?? 0) !== 1 ? 's' : ''} — executed in priority order
|
||||
{rules.length} field-based / document check rule{rules.length !== 1 ? 's' : ''} — applied alongside AI screening
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1199,7 +1345,7 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||
</div>
|
||||
) : rules && rules.length > 0 ? (
|
||||
) : rules.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule: any, idx: number) => {
|
||||
const typeMeta = RULE_TYPE_META[rule.ruleType as RuleType] || RULE_TYPE_META.FIELD_BASED
|
||||
@@ -1212,7 +1358,6 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
className="flex items-center gap-3 rounded-lg border p-3 bg-background hover:bg-muted/30 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<GripVertical className="h-4 w-4 opacity-0 group-hover:opacity-50" />
|
||||
<span className="text-xs font-mono w-5 text-center">{idx + 1}</span>
|
||||
</div>
|
||||
|
||||
@@ -1234,17 +1379,9 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
{config.minFileCount ? `Min ${config.minFileCount} files` : ''}
|
||||
{config.requiredFileTypes ? ` \u00b7 Types: ${(config.requiredFileTypes as string[]).join(', ')}` : ''}
|
||||
{config.maxPages ? ` \u00b7 Max ${config.maxPages} pages` : ''}
|
||||
{config.maxPagesByFileType && Object.keys(config.maxPagesByFileType as object).length > 0
|
||||
? ` \u00b7 Page limits per type`
|
||||
: ''}
|
||||
{' \u2192 '}{config.action as string}
|
||||
</>
|
||||
)}
|
||||
{rule.ruleType === 'AI_SCREENING' && (
|
||||
<>
|
||||
{((config.criteriaText as string) || '').substring(0, 80)}{((config.criteriaText as string) || '').length > 80 ? '...' : ''} → {config.action as string}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1277,15 +1414,14 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<ListFilter className="h-8 w-8 text-muted-foreground mb-3" />
|
||||
<p className="text-sm font-medium">No filtering rules configured</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-3">
|
||||
Add rules to define how projects are screened
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<ListFilter className="h-6 w-6 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No additional rules — AI screening criteria handle everything
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={openCreate}>
|
||||
<Button variant="outline" size="sm" className="mt-2" onClick={openCreate}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add First Rule
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1301,9 +1437,9 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingRule ? 'Edit Rule' : 'Create Filtering Rule'}</DialogTitle>
|
||||
<DialogTitle>{editingRule ? 'Edit Rule' : 'Create Rule'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingRule ? 'Update this filtering rule configuration' : 'Define a new rule for screening projects'}
|
||||
{editingRule ? 'Update this filtering rule' : 'Add a field-based or document check rule'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1329,10 +1465,10 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rule Type Selector */}
|
||||
{/* Rule Type Selector (only Field-Based and Document Check) */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Rule Type</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(Object.entries(RULE_TYPE_META) as [RuleType, typeof RULE_TYPE_META[RuleType]][]).map(([type, m]) => {
|
||||
const Icon = m.icon
|
||||
const selected = form.ruleType === type
|
||||
@@ -1361,17 +1497,15 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
<div className="space-y-3 rounded-lg border p-4 bg-muted/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Conditions</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={form.logic} onValueChange={(v) => setForm((f) => ({ ...f, logic: v as 'AND' | 'OR' }))}>
|
||||
<SelectTrigger className="w-20 h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={form.logic} onValueChange={(v) => setForm((f) => ({ ...f, logic: v as 'AND' | 'OR' }))}>
|
||||
<SelectTrigger className="w-20 h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.conditions.map((cond, i) => {
|
||||
@@ -1586,62 +1720,6 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.ruleType === 'AI_SCREENING' && (
|
||||
<div className="space-y-4 rounded-lg border p-4 bg-muted/20">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground mb-1.5 block">Screening Criteria</Label>
|
||||
<Textarea
|
||||
placeholder="Write the criteria the AI should evaluate against. Example: 1. Ocean conservation impact must be clearly stated 2. Documents must be in English 3. For Business Concepts, academic rigor is acceptable 4. For African projects, apply a lower quality threshold (score >= 5/10)"
|
||||
value={form.criteriaText}
|
||||
onChange={(e) => setForm((f) => ({ ...f, criteriaText: e.target.value }))}
|
||||
rows={8}
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
The AI has access to: category, country, region, founded year, ocean issue, tags, description, file details (type, page count, size, detected language), and team size.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Action</Label>
|
||||
<Select value={form.aiAction} onValueChange={(v) => setForm((f) => ({ ...f, aiAction: v as any }))}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="FLAG">Flag for review</SelectItem>
|
||||
<SelectItem value="REJECT">Auto-reject</SelectItem>
|
||||
<SelectItem value="PASS">Auto-pass</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Batch Size</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
className="h-8 text-xs"
|
||||
value={form.batchSize}
|
||||
onChange={(e) => setForm((f) => ({ ...f, batchSize: parseInt(e.target.value) || 20 }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Parallel Batches</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
className="h-8 text-xs"
|
||||
value={form.parallelBatches}
|
||||
onChange={(e) => setForm((f) => ({ ...f, parallelBatches: parseInt(e.target.value) || 1 }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -1685,3 +1763,45 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Award Tracks Section ───────────────────────────────────────────────────
|
||||
|
||||
function AwardTracksSection({ competitionId, roundId }: { competitionId: string; roundId: string }) {
|
||||
const { data: awards, isLoading } = trpc.specialAward.listForRound.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: !!roundId }
|
||||
)
|
||||
|
||||
if (isLoading) return null
|
||||
if (!awards || awards.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Award className="h-5 w-5 text-amber-600" />
|
||||
Special Award Tracks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Evaluate passed projects against special award criteria and manage shortlists
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{awards.map((award) => (
|
||||
<AwardShortlist
|
||||
key={award.id}
|
||||
awardId={award.id}
|
||||
roundId={roundId}
|
||||
awardName={award.name}
|
||||
criteriaText={award.criteriaText}
|
||||
eligibilityMode={award.eligibilityMode}
|
||||
shortlistSize={award.shortlistSize}
|
||||
jobStatus={award.eligibilityJobStatus}
|
||||
jobTotal={award.eligibilityJobTotal}
|
||||
jobDone={award.eligibilityJobDone}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user