Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination - Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence - Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility - Founding Date Field: add foundedAt to Project model with CSV import support - Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate - Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility - Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures - Reusable pagination component extracted to src/components/shared/pagination.tsx - Old /admin/users and /admin/mentors routes redirect to /admin/members - Prisma migration for all schema additions (additive, no data loss) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
322
src/app/(jury)/jury/awards/[id]/page.tsx
Normal file
322
src/app/(jury)/jury/awards/[id]/page.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
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 { data, isLoading, refetch } =
|
||||
trpc.specialAward.getMyAwardDetail.useQuery({ awardId })
|
||||
const submitVote = trpc.specialAward.submitVote.useMutation()
|
||||
|
||||
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" asChild className="-ml-4">
|
||||
<Link href="/jury/awards">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Awards
|
||||
</Link>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user