Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Fix project list returning empty for users with both SUPER_ADMIN and JURY_MEMBER roles (jury filter now skips admins) in project, assignment, and evaluation routers - Add CountryDisplay component showing flag emoji + name everywhere country is displayed (admin, observer, jury, mentor views — 17 files) - Add countdown deadline banner on applicant dashboard for INTAKE, SUBMISSION, and MENTORING rounds with live timer - Remove quick action buttons from applicant dashboard - Fix competition timeline sidebar: green dots/connectors only up to current round, yellow dot for current round, red connector into rejected round, grey after Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
419 lines
19 KiB
TypeScript
419 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo } from 'react'
|
|
import { trpc } from '@/lib/trpc/client'
|
|
import { toast } from 'sonner'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { 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 {
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Loader2,
|
|
CheckCircle2,
|
|
Play,
|
|
Trophy,
|
|
AlertTriangle,
|
|
Search,
|
|
} from 'lucide-react'
|
|
import { CountryDisplay } from '@/components/shared/country-display'
|
|
|
|
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 [eligibilitySearch, setEligibilitySearch] = useState('')
|
|
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 bulkToggleMutation = trpc.specialAward.bulkToggleShortlisted.useMutation({
|
|
onSuccess: (data) => {
|
|
utils.specialAward.listShortlist.invalidate({ awardId })
|
|
utils.specialAward.listForRound.invalidate({ roundId })
|
|
toast.success(`${data.updated} projects ${data.shortlisted ? 'added to' : 'removed from'} shortlist`)
|
|
},
|
|
onError: (err) => toast.error(`Failed: ${err.message}`),
|
|
})
|
|
|
|
const { data: awardRounds } = trpc.specialAward.listRounds.useQuery(
|
|
{ awardId },
|
|
{ enabled: expanded && eligibilityMode === 'SEPARATE_POOL' }
|
|
)
|
|
const hasAwardRounds = (awardRounds?.length ?? 0) > 0
|
|
|
|
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 filteredEligibilities = useMemo(() => {
|
|
if (!shortlist) return []
|
|
if (!eligibilitySearch.trim()) return shortlist.eligibilities
|
|
const q = eligibilitySearch.toLowerCase()
|
|
return shortlist.eligibilities.filter((e: any) =>
|
|
(e.project?.title || '').toLowerCase().includes(q) ||
|
|
(e.project?.teamName || '').toLowerCase().includes(q) ||
|
|
(e.project?.country || '').toLowerCase().includes(q) ||
|
|
(e.project?.institution || '').toLowerCase().includes(q) ||
|
|
(e.project?.competitionCategory || '').toLowerCase().includes(q)
|
|
)
|
|
}, [shortlist, eligibilitySearch])
|
|
|
|
const shortlistedCount = shortlist?.eligibilities?.filter((e) => e.shortlisted).length ?? 0
|
|
const allShortlisted = shortlist && shortlist.eligibilities.length > 0 && shortlist.eligibilities.every((e) => e.shortlisted)
|
|
const someShortlisted = shortlistedCount > 0 && !allShortlisted
|
|
|
|
|
|
const handleBulkToggle = () => {
|
|
if (!shortlist) return
|
|
const projectIds = shortlist.eligibilities.map((e) => e.project.id)
|
|
const newValue = !allShortlisted
|
|
bulkToggleMutation.mutate({ awardId, projectIds, shortlisted: newValue })
|
|
}
|
|
|
|
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 asChild>
|
|
<div className="space-y-2">
|
|
<p>
|
|
{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.`
|
|
}
|
|
</p>
|
|
{eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && (
|
|
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
|
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
|
<p className="text-sm">
|
|
No award rounds have been created yet. Projects will be confirmed but <strong>not routed</strong> to an evaluation track. Create rounds on the award page first.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</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="mb-3">
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search projects..."
|
|
value={eligibilitySearch}
|
|
onChange={(e) => setEligibilitySearch(e.target.value)}
|
|
className="pl-9 h-9 max-w-sm"
|
|
/>
|
|
</div>
|
|
</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 min-w-[300px]">Reasoning</th>
|
|
<th className="px-3 py-2 text-center w-20">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Checkbox
|
|
checked={allShortlisted ? true : someShortlisted ? 'indeterminate' : false}
|
|
onCheckedChange={handleBulkToggle}
|
|
disabled={bulkToggleMutation.isPending}
|
|
aria-label="Select all"
|
|
/>
|
|
<span className="text-xs">All</span>
|
|
</div>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredEligibilities.map((e, i) => {
|
|
const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined
|
|
const isTop5 = i < shortlistSize
|
|
return (
|
|
<tr key={e.id} className={`border-t ${isTop5 ? 'bg-amber-50/50' : ''}`}>
|
|
<td className="px-3 py-2 text-muted-foreground font-mono">
|
|
{isTop5 ? (
|
|
<span className="text-amber-600 font-semibold">{i + 1}</span>
|
|
) : (
|
|
i + 1
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<div>
|
|
<p className={`font-medium ${isTop5 ? 'text-amber-900' : ''}`}>
|
|
<a
|
|
href={`/admin/projects/${e.project.id}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline font-medium"
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
>
|
|
{e.project.title}
|
|
</a>
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{[e.project.teamName, e.project.competitionCategory].filter(Boolean).length > 0 || e.project.country ? (
|
|
<>
|
|
{[e.project.teamName, e.project.competitionCategory].filter(Boolean).join(', ')}
|
|
{(e.project.teamName || e.project.competitionCategory) && e.project.country ? ', ' : ''}
|
|
{e.project.country && <CountryDisplay country={e.project.country} />}
|
|
</>
|
|
) : '—'}
|
|
</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 whitespace-pre-wrap leading-relaxed">
|
|
{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>
|
|
)
|
|
}
|