Files
MOPC-Portal/src/app/(jury)/jury/awards/[id]/page.tsx
Matt a1e758bc39
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m53s
feat: router.back() navigation, read-only evaluation view, auth audit logging
- Convert all Back buttons platform-wide (38 files) to use router.back()
  for natural browser-back behavior regardless of entry point
- Add read-only view for submitted evaluations in closed rounds with
  blue banner, disabled inputs, and contextual back navigation
- Add auth audit logs: MAGIC_LINK_SENT, PASSWORD_RESET_LINK_CLICKED,
  PASSWORD_RESET_LINK_EXPIRED, PASSWORD_RESET_LINK_INVALID
- Learning Hub links navigate in same window for all roles
- Update settings descriptions to reflect all-user scope

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:25:56 +01:00

327 lines
11 KiB
TypeScript

'use client'
import { use, useState } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import {
ArrowLeft,
Trophy,
CheckCircle2,
Loader2,
GripVertical,
} from 'lucide-react'
import { cn } from '@/lib/utils'
export default function JuryAwardVotingPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: awardId } = use(params)
const router = useRouter()
const utils = trpc.useUtils()
const { data, isLoading, refetch } =
trpc.specialAward.getMyAwardDetail.useQuery({ awardId })
const submitVote = trpc.specialAward.submitVote.useMutation({
onSuccess: () => {
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
},
})
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null
)
const [rankedIds, setRankedIds] = useState<string[]>([])
// Initialize from existing votes
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
if (data.award.scoringMode === 'PICK_WINNER') {
setSelectedProjectId(data.myVotes[0]?.projectId || null)
} else if (data.award.scoringMode === 'RANKED') {
const sorted = [...data.myVotes]
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
.map((v) => v.projectId)
setRankedIds(sorted)
}
}
const handleSubmitPickWinner = async () => {
if (!selectedProjectId) return
try {
await submitVote.mutateAsync({
awardId,
votes: [{ projectId: selectedProjectId }],
})
toast.success('Vote submitted')
refetch()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to submit vote'
)
}
}
const handleSubmitRanked = async () => {
if (rankedIds.length === 0) return
try {
await submitVote.mutateAsync({
awardId,
votes: rankedIds.map((projectId, index) => ({
projectId,
rank: index + 1,
})),
})
toast.success('Rankings submitted')
refetch()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to submit rankings'
)
}
}
const toggleRanked = (projectId: string) => {
if (rankedIds.includes(projectId)) {
setRankedIds(rankedIds.filter((id) => id !== projectId))
} else {
const maxPicks = data?.award.maxRankedPicks || 5
if (rankedIds.length < maxPicks) {
setRankedIds([...rankedIds, projectId])
}
}
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!data) return null
const { award, projects, myVotes } = data
const hasVoted = myVotes.length > 0
const isVotingOpen = award.status === 'VOTING_OPEN'
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => router.back()} className="-ml-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Trophy className="h-6 w-6 text-amber-500" />
{award.name}
</h1>
<div className="flex items-center gap-2 mt-1">
<Badge
variant={isVotingOpen ? 'default' : 'secondary'}
>
{award.status.replace('_', ' ')}
</Badge>
{hasVoted && (
<Badge variant="outline" className="text-green-600">
<CheckCircle2 className="mr-1 h-3 w-3" />
Voted
</Badge>
)}
</div>
{award.criteriaText && (
<p className="text-muted-foreground mt-2">{award.criteriaText}</p>
)}
</div>
{!isVotingOpen ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Voting is not open</p>
<p className="text-sm text-muted-foreground">
Check back when voting opens for this award
</p>
</CardContent>
</Card>
) : award.scoringMode === 'PICK_WINNER' ? (
/* PICK_WINNER Mode */
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Select one project as the winner
</p>
<div className="grid gap-3 sm:grid-cols-2">
{projects.map((project) => (
<Card
key={project.id}
className={cn(
'cursor-pointer transition-all',
selectedProjectId === project.id
? 'ring-2 ring-primary bg-primary/5'
: 'hover:bg-muted/50'
)}
onClick={() => setSelectedProjectId(project.id)}
>
<CardHeader className="pb-2">
<CardTitle className="text-base">{project.title}</CardTitle>
<CardDescription>{project.teamName}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-1">
{project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{project.competitionCategory.replace('_', ' ')}
</Badge>
)}
{project.country && (
<Badge variant="outline" className="text-xs">
{project.country}
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
<div className="flex justify-end">
<Button
onClick={handleSubmitPickWinner}
disabled={!selectedProjectId || submitVote.isPending}
>
{submitVote.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{hasVoted ? 'Update Vote' : 'Submit Vote'}
</Button>
</div>
</div>
) : award.scoringMode === 'RANKED' ? (
/* RANKED Mode */
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Select and rank your top {award.maxRankedPicks || 5} projects. Click
to add/remove, drag to reorder.
</p>
{/* Selected rankings */}
{rankedIds.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Your Rankings</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{rankedIds.map((id, index) => {
const project = projects.find((p) => p.id === id)
if (!project) return null
return (
<div
key={id}
className="flex items-center gap-3 rounded-lg border p-3"
>
<span className="font-bold text-lg w-8 text-center">
{index + 1}
</span>
<GripVertical className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium">{project.title}</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => toggleRanked(id)}
>
Remove
</Button>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Available projects */}
<div className="grid gap-3 sm:grid-cols-2">
{projects
.filter((p) => !rankedIds.includes(p.id))
.map((project) => (
<Card
key={project.id}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => toggleRanked(project.id)}
>
<CardHeader className="pb-2">
<CardTitle className="text-base">
{project.title}
</CardTitle>
<CardDescription>{project.teamName}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-1">
{project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{project.competitionCategory.replace('_', ' ')}
</Badge>
)}
{project.country && (
<Badge variant="outline" className="text-xs">
{project.country}
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
<div className="flex justify-end">
<Button
onClick={handleSubmitRanked}
disabled={rankedIds.length === 0 || submitVote.isPending}
>
{submitVote.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{hasVoted ? 'Update Rankings' : 'Submit Rankings'}
</Button>
</div>
</div>
) : (
/* SCORED Mode — redirect to evaluation */
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Scored Award</p>
<p className="text-sm text-muted-foreground">
This award uses the evaluation system. Check your evaluation
assignments.
</p>
</CardContent>
</Card>
)}
</div>
)
}