Files
MOPC-Portal/src/components/admin/round/award-shortlist.tsx
Matt 37351044ed
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
feat: multi-role jury fix, country flags, applicant deadline banner, timeline
- 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>
2026-03-06 15:00:29 +01:00

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&apos;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 &quot;Run Eligibility&quot; to evaluate projects against this award&apos;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>
)
}