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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user