Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
This commit is contained in:
@@ -1,72 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, ClipboardList } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function JuryError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Jury section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('jury')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this page. Please try again or return to your assignments.'}
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
{isChunk ? (
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={reset} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/jury">
|
||||
<ClipboardList className="mr-2 h-4 w-4" />
|
||||
My Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isChunk && error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, ClipboardList } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function JuryError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Jury section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('jury')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this page. Please try again or return to your assignments.'}
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
{isChunk ? (
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={reset} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/jury">
|
||||
<ClipboardList className="mr-2 h-4 w-4" />
|
||||
My Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isChunk && error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,327 +1,327 @@
|
||||
'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 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" 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>
|
||||
)
|
||||
}
|
||||
'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 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" 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>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,368 +1,368 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ArrowLeft,
|
||||
FileEdit,
|
||||
Eye,
|
||||
ShieldAlert,
|
||||
AlertCircle,
|
||||
ClipboardList,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Type for assignment with included relations from stageAssignment.myAssignments
|
||||
type AssignmentWithRelations = {
|
||||
id: string
|
||||
projectId: string
|
||||
stageId: string
|
||||
isCompleted: boolean
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
country: string | null
|
||||
tags: string[]
|
||||
description: string | null
|
||||
}
|
||||
evaluation?: {
|
||||
id: string
|
||||
status: string
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
submittedAt: Date | null
|
||||
} | null
|
||||
conflictOfInterest?: {
|
||||
id: string
|
||||
hasConflict: boolean
|
||||
conflictType: string | null
|
||||
reviewAction: string | null
|
||||
} | null
|
||||
stage?: {
|
||||
id: string
|
||||
name: string
|
||||
track: {
|
||||
name: string
|
||||
pipeline: { id: string; name: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAssignmentStatus(assignment: {
|
||||
evaluation?: { status: string } | null
|
||||
conflictOfInterest?: { id: string } | null
|
||||
}) {
|
||||
if (assignment.conflictOfInterest) return 'COI'
|
||||
if (!assignment.evaluation) return 'NOT_STARTED'
|
||||
return assignment.evaluation.status
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case 'SUBMITTED':
|
||||
return (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Submitted
|
||||
</Badge>
|
||||
)
|
||||
case 'DRAFT':
|
||||
return (
|
||||
<Badge variant="warning" className="text-xs">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
In Progress
|
||||
</Badge>
|
||||
)
|
||||
case 'COI':
|
||||
return (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<ShieldAlert className="mr-1 h-3 w-3" />
|
||||
COI Declared
|
||||
</Badge>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Not Started
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default function StageAssignmentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
|
||||
const { data: stageInfo, isLoading: stageLoading } =
|
||||
trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
const { data: rawAssignments, isLoading: assignmentsLoading } =
|
||||
trpc.stageAssignment.myAssignments.useQuery({ stageId })
|
||||
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
|
||||
|
||||
const { data: windowStatus } =
|
||||
trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
const isLoading = stageLoading || assignmentsLoading
|
||||
|
||||
const totalAssignments = assignments?.length ?? 0
|
||||
const completedCount = assignments?.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
).length ?? 0
|
||||
const coiCount = assignments?.filter((a) => a.conflictOfInterest).length ?? 0
|
||||
const pendingCount = totalAssignments - completedCount - coiCount
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-20" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={"/jury/stages" as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{stageInfo?.name ?? 'Stage Assignments'}
|
||||
</h1>
|
||||
{stageInfo && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{stageInfo.track.name} · {stageInfo.track.pipeline.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold">{totalAssignments}</p>
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold text-emerald-600">{completedCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Completed</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold text-amber-600">{pendingCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Pending</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold text-red-600">{coiCount}</p>
|
||||
<p className="text-xs text-muted-foreground">COI Declared</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Assignments table */}
|
||||
{assignments && assignments.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignments.map((assignment) => {
|
||||
const status = getAssignmentStatus(assignment)
|
||||
return (
|
||||
<TableRow key={assignment.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/jury/stages/${stageId}/projects/${assignment.project.id}` as Route}
|
||||
className="hover:text-brand-blue dark:hover:text-brand-teal transition-colors"
|
||||
>
|
||||
{assignment.project.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{assignment.project.teamName}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{assignment.project.country ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{status === 'SUBMITTED' ? (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluation` as Route}>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
) : status === 'COI' ? (
|
||||
<Button variant="ghost" size="sm" disabled>
|
||||
<ShieldAlert className="mr-1 h-3 w-3" />
|
||||
COI
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
asChild
|
||||
disabled={!isWindowOpen}
|
||||
className={cn(
|
||||
'bg-brand-blue hover:bg-brand-blue-light',
|
||||
!isWindowOpen && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluate` as Route}>
|
||||
<FileEdit className="mr-1 h-3 w-3" />
|
||||
{status === 'DRAFT' ? 'Continue' : 'Evaluate'}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list */}
|
||||
<div className="md:hidden divide-y">
|
||||
{assignments.map((assignment) => {
|
||||
const status = getAssignmentStatus(assignment)
|
||||
return (
|
||||
<div key={assignment.id} className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href={`/jury/stages/${stageId}/projects/${assignment.project.id}` as Route}
|
||||
className="font-medium text-sm hover:text-brand-blue transition-colors"
|
||||
>
|
||||
{assignment.project.title}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{assignment.project.teamName}
|
||||
{assignment.project.country && ` · ${assignment.project.country}`}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
{status === 'SUBMITTED' ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluation` as Route}>
|
||||
View Evaluation
|
||||
</Link>
|
||||
</Button>
|
||||
) : status !== 'COI' && isWindowOpen ? (
|
||||
<Button size="sm" asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluate` as Route}>
|
||||
{status === 'DRAFT' ? 'Continue' : 'Evaluate'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<ClipboardList className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No assignments in this stage</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Assignments will appear here once an administrator assigns projects to you.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Window closed notice */}
|
||||
{!isWindowOpen && totalAssignments > 0 && completedCount < totalAssignments && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
{windowStatus?.reason ?? 'The evaluation window for this stage is currently closed.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ArrowLeft,
|
||||
FileEdit,
|
||||
Eye,
|
||||
ShieldAlert,
|
||||
AlertCircle,
|
||||
ClipboardList,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Type for assignment with included relations from stageAssignment.myAssignments
|
||||
type AssignmentWithRelations = {
|
||||
id: string
|
||||
projectId: string
|
||||
stageId: string
|
||||
isCompleted: boolean
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
country: string | null
|
||||
tags: string[]
|
||||
description: string | null
|
||||
}
|
||||
evaluation?: {
|
||||
id: string
|
||||
status: string
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
submittedAt: Date | null
|
||||
} | null
|
||||
conflictOfInterest?: {
|
||||
id: string
|
||||
hasConflict: boolean
|
||||
conflictType: string | null
|
||||
reviewAction: string | null
|
||||
} | null
|
||||
stage?: {
|
||||
id: string
|
||||
name: string
|
||||
track: {
|
||||
name: string
|
||||
pipeline: { id: string; name: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAssignmentStatus(assignment: {
|
||||
evaluation?: { status: string } | null
|
||||
conflictOfInterest?: { id: string } | null
|
||||
}) {
|
||||
if (assignment.conflictOfInterest) return 'COI'
|
||||
if (!assignment.evaluation) return 'NOT_STARTED'
|
||||
return assignment.evaluation.status
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case 'SUBMITTED':
|
||||
return (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Submitted
|
||||
</Badge>
|
||||
)
|
||||
case 'DRAFT':
|
||||
return (
|
||||
<Badge variant="warning" className="text-xs">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
In Progress
|
||||
</Badge>
|
||||
)
|
||||
case 'COI':
|
||||
return (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<ShieldAlert className="mr-1 h-3 w-3" />
|
||||
COI Declared
|
||||
</Badge>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Not Started
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default function StageAssignmentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
|
||||
const { data: stageInfo, isLoading: stageLoading } =
|
||||
trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
const { data: rawAssignments, isLoading: assignmentsLoading } =
|
||||
trpc.stageAssignment.myAssignments.useQuery({ stageId })
|
||||
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
|
||||
|
||||
const { data: windowStatus } =
|
||||
trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
const isLoading = stageLoading || assignmentsLoading
|
||||
|
||||
const totalAssignments = assignments?.length ?? 0
|
||||
const completedCount = assignments?.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
).length ?? 0
|
||||
const coiCount = assignments?.filter((a) => a.conflictOfInterest).length ?? 0
|
||||
const pendingCount = totalAssignments - completedCount - coiCount
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-20" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={"/jury/stages" as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{stageInfo?.name ?? 'Stage Assignments'}
|
||||
</h1>
|
||||
{stageInfo && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{stageInfo.track.name} · {stageInfo.track.pipeline.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold">{totalAssignments}</p>
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold text-emerald-600">{completedCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Completed</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold text-amber-600">{pendingCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Pending</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold text-red-600">{coiCount}</p>
|
||||
<p className="text-xs text-muted-foreground">COI Declared</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Assignments table */}
|
||||
{assignments && assignments.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignments.map((assignment) => {
|
||||
const status = getAssignmentStatus(assignment)
|
||||
return (
|
||||
<TableRow key={assignment.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/jury/stages/${stageId}/projects/${assignment.project.id}` as Route}
|
||||
className="hover:text-brand-blue dark:hover:text-brand-teal transition-colors"
|
||||
>
|
||||
{assignment.project.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{assignment.project.teamName}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{assignment.project.country ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{status === 'SUBMITTED' ? (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluation` as Route}>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
) : status === 'COI' ? (
|
||||
<Button variant="ghost" size="sm" disabled>
|
||||
<ShieldAlert className="mr-1 h-3 w-3" />
|
||||
COI
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
asChild
|
||||
disabled={!isWindowOpen}
|
||||
className={cn(
|
||||
'bg-brand-blue hover:bg-brand-blue-light',
|
||||
!isWindowOpen && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluate` as Route}>
|
||||
<FileEdit className="mr-1 h-3 w-3" />
|
||||
{status === 'DRAFT' ? 'Continue' : 'Evaluate'}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list */}
|
||||
<div className="md:hidden divide-y">
|
||||
{assignments.map((assignment) => {
|
||||
const status = getAssignmentStatus(assignment)
|
||||
return (
|
||||
<div key={assignment.id} className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href={`/jury/stages/${stageId}/projects/${assignment.project.id}` as Route}
|
||||
className="font-medium text-sm hover:text-brand-blue transition-colors"
|
||||
>
|
||||
{assignment.project.title}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{assignment.project.teamName}
|
||||
{assignment.project.country && ` · ${assignment.project.country}`}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
{status === 'SUBMITTED' ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluation` as Route}>
|
||||
View Evaluation
|
||||
</Link>
|
||||
</Button>
|
||||
) : status !== 'COI' && isWindowOpen ? (
|
||||
<Button size="sm" asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluate` as Route}>
|
||||
{status === 'DRAFT' ? 'Continue' : 'Evaluate'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<ClipboardList className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No assignments in this stage</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Assignments will appear here once an administrator assigns projects to you.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Window closed notice */}
|
||||
{!isWindowOpen && totalAssignments > 0 && completedCount < totalAssignments && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
{windowStatus?.reason ?? 'The evaluation window for this stage is currently closed.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,311 +1,311 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
ArrowLeft,
|
||||
GitCompare,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Type for assignment with included relations from stageAssignment.myAssignments
|
||||
type AssignmentWithRelations = {
|
||||
id: string
|
||||
projectId: string
|
||||
stageId: string
|
||||
isCompleted: boolean
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
country: string | null
|
||||
tags: string[]
|
||||
description: string | null
|
||||
}
|
||||
evaluation?: {
|
||||
id: string
|
||||
status: string
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
submittedAt: Date | null
|
||||
} | null
|
||||
conflictOfInterest?: {
|
||||
id: string
|
||||
hasConflict: boolean
|
||||
conflictType: string | null
|
||||
reviewAction: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export default function StageComparePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: rawAssignments, isLoading: assignmentsLoading } =
|
||||
trpc.stageAssignment.myAssignments.useQuery({ stageId })
|
||||
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: evaluations } =
|
||||
trpc.evaluation.listStageEvaluations.useQuery({ stageId })
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
|
||||
const criteria = stageForm?.criteriaJson?.filter(
|
||||
(c: { type?: string }) => c.type !== 'section_header'
|
||||
) ?? []
|
||||
|
||||
// Map evaluations by project ID
|
||||
const evalByProject = useMemo(() => {
|
||||
const map = new Map<string, (typeof evaluations extends (infer T)[] | undefined ? T : never)>()
|
||||
evaluations?.forEach((e) => {
|
||||
if (e.assignment?.projectId) {
|
||||
map.set(e.assignment.projectId, e)
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, [evaluations])
|
||||
|
||||
const toggleProject = (projectId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(projectId)) {
|
||||
next.delete(projectId)
|
||||
} else if (next.size < 4) {
|
||||
next.add(projectId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectedAssignments = assignments?.filter((a) =>
|
||||
selectedIds.has(a.project.id)
|
||||
) ?? []
|
||||
|
||||
if (assignmentsLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const submittedAssignments = assignments?.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
) ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<GitCompare className="h-6 w-6" />
|
||||
Compare Projects
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Select 2-4 evaluated projects to compare side-by-side
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{submittedAssignments.length < 2 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Not enough evaluations</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You need at least 2 submitted evaluations to compare projects.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Project selector */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Select Projects ({selectedIds.size}/4)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{submittedAssignments.map((assignment) => {
|
||||
const isSelected = selectedIds.has(assignment.project.id)
|
||||
const eval_ = evalByProject.get(assignment.project.id)
|
||||
return (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
|
||||
isSelected
|
||||
? 'border-brand-blue bg-brand-blue/5 dark:border-brand-teal dark:bg-brand-teal/5'
|
||||
: 'hover:bg-muted/50',
|
||||
selectedIds.size >= 4 && !isSelected && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
onClick={() => toggleProject(assignment.project.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={selectedIds.size >= 4 && !isSelected}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{assignment.project.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignment.project.teamName}
|
||||
</p>
|
||||
</div>
|
||||
{eval_?.globalScore != null && (
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
<Star className="mr-1 h-3 w-3 text-amber-500" />
|
||||
{eval_.globalScore.toFixed(1)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Comparison table */}
|
||||
{selectedAssignments.length >= 2 && (
|
||||
<Card>
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[140px]">Criterion</TableHead>
|
||||
{selectedAssignments.map((a) => (
|
||||
<TableHead key={a.id} className="text-center min-w-[120px]">
|
||||
<div className="truncate max-w-[120px]" title={a.project.title}>
|
||||
{a.project.title}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* Criterion rows */}
|
||||
{criteria.map((criterion: { id: string; label: string; type?: string; scale?: string | number }) => (
|
||||
<TableRow key={criterion.id}>
|
||||
<TableCell className="font-medium text-sm">
|
||||
{criterion.label}
|
||||
</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
const scores = eval_?.criterionScoresJson as Record<string, number | string | boolean> | null
|
||||
const score = scores?.[criterion.id]
|
||||
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
{criterion.type === 'boolean' ? (
|
||||
score ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600 mx-auto" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
) : criterion.type === 'text' ? (
|
||||
<span className="text-xs truncate max-w-[100px] block">
|
||||
{String(score ?? '—')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="tabular-nums font-semibold">
|
||||
{typeof score === 'number' ? score.toFixed(1) : '—'}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{/* Global score row */}
|
||||
<TableRow className="bg-muted/50 font-semibold">
|
||||
<TableCell>Global Score</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
<span className="tabular-nums text-lg">
|
||||
{eval_?.globalScore?.toFixed(1) ?? '—'}
|
||||
</span>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
|
||||
{/* Decision row */}
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Decision</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
{eval_?.binaryDecision != null ? (
|
||||
<Badge variant={eval_.binaryDecision ? 'success' : 'destructive'}>
|
||||
{eval_.binaryDecision ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use, useState, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
ArrowLeft,
|
||||
GitCompare,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Type for assignment with included relations from stageAssignment.myAssignments
|
||||
type AssignmentWithRelations = {
|
||||
id: string
|
||||
projectId: string
|
||||
stageId: string
|
||||
isCompleted: boolean
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
country: string | null
|
||||
tags: string[]
|
||||
description: string | null
|
||||
}
|
||||
evaluation?: {
|
||||
id: string
|
||||
status: string
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
submittedAt: Date | null
|
||||
} | null
|
||||
conflictOfInterest?: {
|
||||
id: string
|
||||
hasConflict: boolean
|
||||
conflictType: string | null
|
||||
reviewAction: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export default function StageComparePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: rawAssignments, isLoading: assignmentsLoading } =
|
||||
trpc.stageAssignment.myAssignments.useQuery({ stageId })
|
||||
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: evaluations } =
|
||||
trpc.evaluation.listStageEvaluations.useQuery({ stageId })
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
|
||||
const criteria = stageForm?.criteriaJson?.filter(
|
||||
(c: { type?: string }) => c.type !== 'section_header'
|
||||
) ?? []
|
||||
|
||||
// Map evaluations by project ID
|
||||
const evalByProject = useMemo(() => {
|
||||
const map = new Map<string, (typeof evaluations extends (infer T)[] | undefined ? T : never)>()
|
||||
evaluations?.forEach((e) => {
|
||||
if (e.assignment?.projectId) {
|
||||
map.set(e.assignment.projectId, e)
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, [evaluations])
|
||||
|
||||
const toggleProject = (projectId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(projectId)) {
|
||||
next.delete(projectId)
|
||||
} else if (next.size < 4) {
|
||||
next.add(projectId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectedAssignments = assignments?.filter((a) =>
|
||||
selectedIds.has(a.project.id)
|
||||
) ?? []
|
||||
|
||||
if (assignmentsLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const submittedAssignments = assignments?.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
) ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<GitCompare className="h-6 w-6" />
|
||||
Compare Projects
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Select 2-4 evaluated projects to compare side-by-side
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{submittedAssignments.length < 2 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Not enough evaluations</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You need at least 2 submitted evaluations to compare projects.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Project selector */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Select Projects ({selectedIds.size}/4)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{submittedAssignments.map((assignment) => {
|
||||
const isSelected = selectedIds.has(assignment.project.id)
|
||||
const eval_ = evalByProject.get(assignment.project.id)
|
||||
return (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
|
||||
isSelected
|
||||
? 'border-brand-blue bg-brand-blue/5 dark:border-brand-teal dark:bg-brand-teal/5'
|
||||
: 'hover:bg-muted/50',
|
||||
selectedIds.size >= 4 && !isSelected && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
onClick={() => toggleProject(assignment.project.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={selectedIds.size >= 4 && !isSelected}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{assignment.project.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignment.project.teamName}
|
||||
</p>
|
||||
</div>
|
||||
{eval_?.globalScore != null && (
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
<Star className="mr-1 h-3 w-3 text-amber-500" />
|
||||
{eval_.globalScore.toFixed(1)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Comparison table */}
|
||||
{selectedAssignments.length >= 2 && (
|
||||
<Card>
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[140px]">Criterion</TableHead>
|
||||
{selectedAssignments.map((a) => (
|
||||
<TableHead key={a.id} className="text-center min-w-[120px]">
|
||||
<div className="truncate max-w-[120px]" title={a.project.title}>
|
||||
{a.project.title}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* Criterion rows */}
|
||||
{criteria.map((criterion: { id: string; label: string; type?: string; scale?: string | number }) => (
|
||||
<TableRow key={criterion.id}>
|
||||
<TableCell className="font-medium text-sm">
|
||||
{criterion.label}
|
||||
</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
const scores = eval_?.criterionScoresJson as Record<string, number | string | boolean> | null
|
||||
const score = scores?.[criterion.id]
|
||||
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
{criterion.type === 'boolean' ? (
|
||||
score ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600 mx-auto" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
) : criterion.type === 'text' ? (
|
||||
<span className="text-xs truncate max-w-[100px] block">
|
||||
{String(score ?? '—')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="tabular-nums font-semibold">
|
||||
{typeof score === 'number' ? score.toFixed(1) : '—'}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{/* Global score row */}
|
||||
<TableRow className="bg-muted/50 font-semibold">
|
||||
<TableCell>Global Score</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
<span className="tabular-nums text-lg">
|
||||
{eval_?.globalScore?.toFixed(1) ?? '—'}
|
||||
</span>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
|
||||
{/* Decision row */}
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Decision</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
{eval_?.binaryDecision != null ? (
|
||||
<Badge variant={eval_.binaryDecision ? 'success' : 'destructive'}>
|
||||
{eval_.binaryDecision ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,269 +1,269 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Pause,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function StageJuryLivePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [hasVoted, setHasVoted] = useState(false)
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
// Get live cursor for this stage
|
||||
const { data: cursorData } = trpc.live.getCursor.useQuery(
|
||||
{ stageId },
|
||||
{ enabled: !!stageId }
|
||||
)
|
||||
|
||||
const sessionId = cursorData?.sessionId ?? null
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
activeProject,
|
||||
isPaused,
|
||||
error: sseError,
|
||||
reconnect,
|
||||
} = useStageliveSse(sessionId)
|
||||
|
||||
// Reset vote state when active project changes
|
||||
const activeProjectId = activeProject?.id
|
||||
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
|
||||
|
||||
if (activeProjectId && activeProjectId !== lastVotedProjectId && hasVoted) {
|
||||
setHasVoted(false)
|
||||
setSelectedScore(null)
|
||||
}
|
||||
|
||||
const castVoteMutation = trpc.live.castStageVote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Vote submitted!')
|
||||
setHasVoted(true)
|
||||
setSelectedScore(null)
|
||||
setLastVotedProjectId(activeProjectId ?? null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleVote = () => {
|
||||
if (!sessionId || !activeProject || selectedScore === null) return
|
||||
castVoteMutation.mutate({
|
||||
sessionId,
|
||||
projectId: activeProject.id,
|
||||
score: selectedScore,
|
||||
})
|
||||
}
|
||||
|
||||
if (!cursorData && !stageInfo) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No live session active</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
A live presentation session has not been started for this stage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header with connection status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Live Voting</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
Connected
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
Disconnected
|
||||
</Badge>
|
||||
)}
|
||||
{!isConnected && (
|
||||
<Button variant="outline" size="sm" onClick={reconnect}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sseError && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">{sseError}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Paused overlay */}
|
||||
{isPaused ? (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Pause className="h-12 w-12 text-amber-600 mb-3" />
|
||||
<p className="text-lg font-semibold">Session Paused</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The session administrator has paused voting. Please wait...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : activeProject ? (
|
||||
<>
|
||||
{/* Active project card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
|
||||
{activeProject.teamName && (
|
||||
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
{activeProject.description && (
|
||||
<CardContent>
|
||||
<p className="text-sm">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Voting controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500" />
|
||||
Cast Your Vote
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasVoted ? (
|
||||
<div className="flex flex-col items-center py-6 text-center">
|
||||
<CheckCircle2 className="h-12 w-12 text-emerald-600 mb-3" />
|
||||
<p className="font-semibold">Vote submitted!</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Waiting for the next project...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-5 gap-2 sm:grid-cols-10">
|
||||
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-12 text-lg font-bold tabular-nums',
|
||||
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light'
|
||||
)}
|
||||
onClick={() => setSelectedScore(score)}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-brand-blue hover:bg-brand-blue-light"
|
||||
size="lg"
|
||||
disabled={selectedScore === null || castVoteMutation.isPending}
|
||||
onClick={handleVote}
|
||||
>
|
||||
{castVoteMutation.isPending ? 'Submitting...' : 'Submit Vote'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Star className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Waiting for next project...</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The session administrator will advance to the next project.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Pause,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function StageJuryLivePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [hasVoted, setHasVoted] = useState(false)
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
// Get live cursor for this stage
|
||||
const { data: cursorData } = trpc.live.getCursor.useQuery(
|
||||
{ stageId },
|
||||
{ enabled: !!stageId }
|
||||
)
|
||||
|
||||
const sessionId = cursorData?.sessionId ?? null
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
activeProject,
|
||||
isPaused,
|
||||
error: sseError,
|
||||
reconnect,
|
||||
} = useStageliveSse(sessionId)
|
||||
|
||||
// Reset vote state when active project changes
|
||||
const activeProjectId = activeProject?.id
|
||||
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
|
||||
|
||||
if (activeProjectId && activeProjectId !== lastVotedProjectId && hasVoted) {
|
||||
setHasVoted(false)
|
||||
setSelectedScore(null)
|
||||
}
|
||||
|
||||
const castVoteMutation = trpc.live.castStageVote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Vote submitted!')
|
||||
setHasVoted(true)
|
||||
setSelectedScore(null)
|
||||
setLastVotedProjectId(activeProjectId ?? null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleVote = () => {
|
||||
if (!sessionId || !activeProject || selectedScore === null) return
|
||||
castVoteMutation.mutate({
|
||||
sessionId,
|
||||
projectId: activeProject.id,
|
||||
score: selectedScore,
|
||||
})
|
||||
}
|
||||
|
||||
if (!cursorData && !stageInfo) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No live session active</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
A live presentation session has not been started for this stage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header with connection status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Live Voting</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
Connected
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
Disconnected
|
||||
</Badge>
|
||||
)}
|
||||
{!isConnected && (
|
||||
<Button variant="outline" size="sm" onClick={reconnect}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sseError && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">{sseError}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Paused overlay */}
|
||||
{isPaused ? (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Pause className="h-12 w-12 text-amber-600 mb-3" />
|
||||
<p className="text-lg font-semibold">Session Paused</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The session administrator has paused voting. Please wait...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : activeProject ? (
|
||||
<>
|
||||
{/* Active project card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
|
||||
{activeProject.teamName && (
|
||||
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
{activeProject.description && (
|
||||
<CardContent>
|
||||
<p className="text-sm">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Voting controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500" />
|
||||
Cast Your Vote
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasVoted ? (
|
||||
<div className="flex flex-col items-center py-6 text-center">
|
||||
<CheckCircle2 className="h-12 w-12 text-emerald-600 mb-3" />
|
||||
<p className="font-semibold">Vote submitted!</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Waiting for the next project...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-5 gap-2 sm:grid-cols-10">
|
||||
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-12 text-lg font-bold tabular-nums',
|
||||
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light'
|
||||
)}
|
||||
onClick={() => setSelectedScore(score)}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-brand-blue hover:bg-brand-blue-light"
|
||||
size="lg"
|
||||
disabled={selectedScore === null || castVoteMutation.isPending}
|
||||
onClick={handleVote}
|
||||
>
|
||||
{castVoteMutation.isPending ? 'Submitting...' : 'Submit Vote'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Star className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Waiting for next project...</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The session administrator will advance to the next project.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,199 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, AlertCircle } from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
|
||||
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
|
||||
|
||||
export default function StageEvaluatePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
// Fetch assignment details
|
||||
const { data: assignment, isLoading: assignmentLoading } =
|
||||
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
|
||||
|
||||
// Fetch stage info for breadcrumb
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
// Fetch or create evaluation draft
|
||||
const startEval = trpc.evaluation.startStage.useMutation()
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
// State for the evaluation returned by the mutation
|
||||
const [evaluation, setEvaluation] = useState<{
|
||||
id: string
|
||||
status: string
|
||||
criterionScoresJson?: unknown
|
||||
globalScore?: number | null
|
||||
binaryDecision?: boolean | null
|
||||
feedbackText?: string | null
|
||||
} | null>(null)
|
||||
|
||||
// Start evaluation on first load if we have the assignment
|
||||
useEffect(() => {
|
||||
if (assignment && !evaluation && !startEval.isPending && (windowStatus?.isOpen ?? false)) {
|
||||
startEval.mutate(
|
||||
{ assignmentId: assignment.id, stageId },
|
||||
{ onSuccess: (data) => setEvaluation(data) }
|
||||
)
|
||||
}
|
||||
}, [assignment?.id, windowStatus?.isOpen])
|
||||
|
||||
const isLoading = assignmentLoading || startEval.isPending
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50 mb-3" />
|
||||
<p className="font-medium text-destructive">Assignment not found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You don't have an assignment for this project in this stage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
const criteria = stageForm?.criteriaJson ?? []
|
||||
|
||||
// Get COI status from assignment
|
||||
const coiStatus = {
|
||||
hasConflict: !!assignment.conflictOfInterest,
|
||||
declared: !!assignment.conflictOfInterest,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project title + stage window */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{assignment.project.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{assignment.project.teamName}
|
||||
{assignment.project.country && ` · ${assignment.project.country}`}
|
||||
</p>
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grace period notice */}
|
||||
{windowStatus?.hasGracePeriod && windowStatus?.graceExpiresAt && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
You are in a grace period. Please submit your evaluation before{' '}
|
||||
{new Date(windowStatus.graceExpiresAt).toLocaleString()}.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Window closed notice */}
|
||||
{!isWindowOpen && !windowStatus?.hasGracePeriod && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">
|
||||
{windowStatus?.reason ?? 'The evaluation window for this stage is closed.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Project files */}
|
||||
<CollapsibleFilesSection
|
||||
projectId={projectId}
|
||||
fileCount={assignment.project.files?.length ?? 0}
|
||||
stageId={stageId}
|
||||
/>
|
||||
|
||||
{/* Evaluation form */}
|
||||
{isWindowOpen || windowStatus?.hasGracePeriod ? (
|
||||
<EvaluationFormWithCOI
|
||||
assignmentId={assignment.id}
|
||||
evaluationId={evaluation?.id ?? null}
|
||||
projectTitle={assignment.project.title}
|
||||
criteria={criteria as Array<{ id: string; label: string; description?: string; type?: 'numeric' | 'text' | 'boolean' | 'section_header'; scale?: number; weight?: number; required?: boolean }>}
|
||||
initialData={
|
||||
evaluation
|
||||
? {
|
||||
criterionScoresJson:
|
||||
evaluation.criterionScoresJson as Record<string, number | string | boolean> | null,
|
||||
globalScore: evaluation.globalScore ?? null,
|
||||
binaryDecision: evaluation.binaryDecision ?? null,
|
||||
feedbackText: evaluation.feedbackText ?? null,
|
||||
status: evaluation.status,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
isVotingOpen={isWindowOpen || !!windowStatus?.hasGracePeriod}
|
||||
deadline={
|
||||
windowStatus?.graceExpiresAt
|
||||
? new Date(windowStatus.graceExpiresAt)
|
||||
: stageInfo?.windowCloseAt
|
||||
? new Date(stageInfo.windowCloseAt)
|
||||
: null
|
||||
}
|
||||
coiStatus={coiStatus}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, AlertCircle } from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
|
||||
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
|
||||
|
||||
export default function StageEvaluatePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
// Fetch assignment details
|
||||
const { data: assignment, isLoading: assignmentLoading } =
|
||||
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
|
||||
|
||||
// Fetch stage info for breadcrumb
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
// Fetch or create evaluation draft
|
||||
const startEval = trpc.evaluation.startStage.useMutation()
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
// State for the evaluation returned by the mutation
|
||||
const [evaluation, setEvaluation] = useState<{
|
||||
id: string
|
||||
status: string
|
||||
criterionScoresJson?: unknown
|
||||
globalScore?: number | null
|
||||
binaryDecision?: boolean | null
|
||||
feedbackText?: string | null
|
||||
} | null>(null)
|
||||
|
||||
// Start evaluation on first load if we have the assignment
|
||||
useEffect(() => {
|
||||
if (assignment && !evaluation && !startEval.isPending && (windowStatus?.isOpen ?? false)) {
|
||||
startEval.mutate(
|
||||
{ assignmentId: assignment.id, stageId },
|
||||
{ onSuccess: (data) => setEvaluation(data) }
|
||||
)
|
||||
}
|
||||
}, [assignment?.id, windowStatus?.isOpen])
|
||||
|
||||
const isLoading = assignmentLoading || startEval.isPending
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50 mb-3" />
|
||||
<p className="font-medium text-destructive">Assignment not found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You don't have an assignment for this project in this stage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
const criteria = stageForm?.criteriaJson ?? []
|
||||
|
||||
// Get COI status from assignment
|
||||
const coiStatus = {
|
||||
hasConflict: !!assignment.conflictOfInterest,
|
||||
declared: !!assignment.conflictOfInterest,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project title + stage window */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{assignment.project.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{assignment.project.teamName}
|
||||
{assignment.project.country && ` · ${assignment.project.country}`}
|
||||
</p>
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grace period notice */}
|
||||
{windowStatus?.hasGracePeriod && windowStatus?.graceExpiresAt && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
You are in a grace period. Please submit your evaluation before{' '}
|
||||
{new Date(windowStatus.graceExpiresAt).toLocaleString()}.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Window closed notice */}
|
||||
{!isWindowOpen && !windowStatus?.hasGracePeriod && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">
|
||||
{windowStatus?.reason ?? 'The evaluation window for this stage is closed.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Project files */}
|
||||
<CollapsibleFilesSection
|
||||
projectId={projectId}
|
||||
fileCount={assignment.project.files?.length ?? 0}
|
||||
stageId={stageId}
|
||||
/>
|
||||
|
||||
{/* Evaluation form */}
|
||||
{isWindowOpen || windowStatus?.hasGracePeriod ? (
|
||||
<EvaluationFormWithCOI
|
||||
assignmentId={assignment.id}
|
||||
evaluationId={evaluation?.id ?? null}
|
||||
projectTitle={assignment.project.title}
|
||||
criteria={criteria as Array<{ id: string; label: string; description?: string; type?: 'numeric' | 'text' | 'boolean' | 'section_header'; scale?: number; weight?: number; required?: boolean }>}
|
||||
initialData={
|
||||
evaluation
|
||||
? {
|
||||
criterionScoresJson:
|
||||
evaluation.criterionScoresJson as Record<string, number | string | boolean> | null,
|
||||
globalScore: evaluation.globalScore ?? null,
|
||||
binaryDecision: evaluation.binaryDecision ?? null,
|
||||
feedbackText: evaluation.feedbackText ?? null,
|
||||
status: evaluation.status,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
isVotingOpen={isWindowOpen || !!windowStatus?.hasGracePeriod}
|
||||
deadline={
|
||||
windowStatus?.graceExpiresAt
|
||||
? new Date(windowStatus.graceExpiresAt)
|
||||
: stageInfo?.windowCloseAt
|
||||
? new Date(stageInfo.windowCloseAt)
|
||||
: null
|
||||
}
|
||||
coiStatus={coiStatus}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,235 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Star,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
|
||||
export default function ViewStageEvaluationPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
const { data: evaluations, isLoading } =
|
||||
trpc.evaluation.listStageEvaluations.useQuery({ stageId, projectId })
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
|
||||
const evaluation = evaluations?.[0] // Most recent evaluation
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!evaluation) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No evaluation found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You haven't submitted an evaluation for this project yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const criterionScores = evaluation.criterionScoresJson as Record<string, number | string | boolean> | null
|
||||
const criteria = (stageForm?.criteriaJson as Array<{ id: string; label: string; type?: string; scale?: number }>) ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{evaluation.assignment?.project?.title ?? 'Evaluation'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Submitted evaluation — read only
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="success" className="self-start">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Submitted
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Submission info */}
|
||||
{evaluation.submittedAt && (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-2 py-3">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Submitted on{' '}
|
||||
{new Date(evaluation.submittedAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Criterion scores */}
|
||||
{criteria.length > 0 && criterionScores && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Criterion Scores</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{criteria.map((criterion) => {
|
||||
const score = criterionScores[criterion.id]
|
||||
if (criterion.type === 'section_header') {
|
||||
return (
|
||||
<div key={criterion.id} className="pt-2">
|
||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
|
||||
{criterion.label}
|
||||
</h3>
|
||||
<Separator className="mt-2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={criterion.id} className="flex items-center justify-between py-2">
|
||||
<span className="text-sm font-medium">{criterion.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{criterion.type === 'boolean' ? (
|
||||
<Badge variant={score ? 'success' : 'secondary'}>
|
||||
{score ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
) : criterion.type === 'text' ? (
|
||||
<span className="text-sm text-muted-foreground max-w-[200px] truncate">
|
||||
{String(score ?? '—')}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 text-amber-500" />
|
||||
<span className="font-semibold tabular-nums">
|
||||
{typeof score === 'number' ? score.toFixed(1) : '—'}
|
||||
</span>
|
||||
{criterion.scale && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {criterion.scale}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Global score + Decision */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500" />
|
||||
Global Score
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-4xl font-bold tabular-nums">
|
||||
{evaluation.globalScore?.toFixed(1) ?? '—'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{evaluation.binaryDecision !== null && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Decision</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge
|
||||
variant={evaluation.binaryDecision ? 'success' : 'destructive'}
|
||||
className="text-lg px-4 py-2"
|
||||
>
|
||||
{evaluation.binaryDecision ? 'Recommend' : 'Do Not Recommend'}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{evaluation.feedbackText && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Feedback
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p className="whitespace-pre-wrap">{evaluation.feedbackText}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Star,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
|
||||
export default function ViewStageEvaluationPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
const { data: evaluations, isLoading } =
|
||||
trpc.evaluation.listStageEvaluations.useQuery({ stageId, projectId })
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
|
||||
const evaluation = evaluations?.[0] // Most recent evaluation
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!evaluation) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No evaluation found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You haven't submitted an evaluation for this project yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const criterionScores = evaluation.criterionScoresJson as Record<string, number | string | boolean> | null
|
||||
const criteria = (stageForm?.criteriaJson as Array<{ id: string; label: string; type?: string; scale?: number }>) ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{evaluation.assignment?.project?.title ?? 'Evaluation'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Submitted evaluation — read only
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="success" className="self-start">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Submitted
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Submission info */}
|
||||
{evaluation.submittedAt && (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-2 py-3">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Submitted on{' '}
|
||||
{new Date(evaluation.submittedAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Criterion scores */}
|
||||
{criteria.length > 0 && criterionScores && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Criterion Scores</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{criteria.map((criterion) => {
|
||||
const score = criterionScores[criterion.id]
|
||||
if (criterion.type === 'section_header') {
|
||||
return (
|
||||
<div key={criterion.id} className="pt-2">
|
||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
|
||||
{criterion.label}
|
||||
</h3>
|
||||
<Separator className="mt-2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={criterion.id} className="flex items-center justify-between py-2">
|
||||
<span className="text-sm font-medium">{criterion.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{criterion.type === 'boolean' ? (
|
||||
<Badge variant={score ? 'success' : 'secondary'}>
|
||||
{score ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
) : criterion.type === 'text' ? (
|
||||
<span className="text-sm text-muted-foreground max-w-[200px] truncate">
|
||||
{String(score ?? '—')}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 text-amber-500" />
|
||||
<span className="font-semibold tabular-nums">
|
||||
{typeof score === 'number' ? score.toFixed(1) : '—'}
|
||||
</span>
|
||||
{criterion.scale && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {criterion.scale}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Global score + Decision */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500" />
|
||||
Global Score
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-4xl font-bold tabular-nums">
|
||||
{evaluation.globalScore?.toFixed(1) ?? '—'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{evaluation.binaryDecision !== null && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Decision</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge
|
||||
variant={evaluation.binaryDecision ? 'success' : 'destructive'}
|
||||
className="text-lg px-4 py-2"
|
||||
>
|
||||
{evaluation.binaryDecision ? 'Recommend' : 'Do Not Recommend'}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{evaluation.feedbackText && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Feedback
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p className="whitespace-pre-wrap">{evaluation.feedbackText}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,217 +1,217 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileEdit,
|
||||
Eye,
|
||||
Users,
|
||||
MapPin,
|
||||
Tag,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
|
||||
|
||||
function EvalStatusCard({
|
||||
status,
|
||||
stageId,
|
||||
projectId,
|
||||
isWindowOpen,
|
||||
}: {
|
||||
status: string
|
||||
stageId: string
|
||||
projectId: string
|
||||
isWindowOpen: boolean
|
||||
}) {
|
||||
const isSubmitted = status === 'SUBMITTED'
|
||||
const isDraft = status === 'DRAFT'
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Evaluation Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant={
|
||||
isSubmitted ? 'success' : isDraft ? 'warning' : 'secondary'
|
||||
}
|
||||
>
|
||||
{isSubmitted ? 'Submitted' : isDraft ? 'In Progress' : 'Not Started'}
|
||||
</Badge>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{isSubmitted ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluation` as Route}>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
View Evaluation
|
||||
</Link>
|
||||
</Button>
|
||||
) : isWindowOpen ? (
|
||||
<Button size="sm" asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluate` as Route}>
|
||||
<FileEdit className="mr-1 h-3 w-3" />
|
||||
{isDraft ? 'Continue Evaluation' : 'Start Evaluation'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Window Closed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StageProjectDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
const { data: assignment, isLoading: assignmentLoading } =
|
||||
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
|
||||
if (assignmentLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50 mb-3" />
|
||||
<p className="font-medium text-destructive">Assignment not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const project = assignment.project
|
||||
const evalStatus = assignment.evaluation?.status ?? 'NOT_STARTED'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{project.title}</h1>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
|
||||
{project.teamName && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{project.teamName}
|
||||
</span>
|
||||
)}
|
||||
{project.country && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{project.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project description */}
|
||||
{project.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Description</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
{project.tags.map((tag: string) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Evaluation status */}
|
||||
<EvalStatusCard
|
||||
status={evalStatus}
|
||||
stageId={stageId}
|
||||
projectId={projectId}
|
||||
isWindowOpen={isWindowOpen}
|
||||
/>
|
||||
|
||||
{/* Project files */}
|
||||
<CollapsibleFilesSection
|
||||
projectId={projectId}
|
||||
fileCount={project.files?.length ?? 0}
|
||||
stageId={stageId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileEdit,
|
||||
Eye,
|
||||
Users,
|
||||
MapPin,
|
||||
Tag,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
|
||||
|
||||
function EvalStatusCard({
|
||||
status,
|
||||
stageId,
|
||||
projectId,
|
||||
isWindowOpen,
|
||||
}: {
|
||||
status: string
|
||||
stageId: string
|
||||
projectId: string
|
||||
isWindowOpen: boolean
|
||||
}) {
|
||||
const isSubmitted = status === 'SUBMITTED'
|
||||
const isDraft = status === 'DRAFT'
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Evaluation Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant={
|
||||
isSubmitted ? 'success' : isDraft ? 'warning' : 'secondary'
|
||||
}
|
||||
>
|
||||
{isSubmitted ? 'Submitted' : isDraft ? 'In Progress' : 'Not Started'}
|
||||
</Badge>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{isSubmitted ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluation` as Route}>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
View Evaluation
|
||||
</Link>
|
||||
</Button>
|
||||
) : isWindowOpen ? (
|
||||
<Button size="sm" asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluate` as Route}>
|
||||
<FileEdit className="mr-1 h-3 w-3" />
|
||||
{isDraft ? 'Continue Evaluation' : 'Start Evaluation'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Window Closed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StageProjectDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
const { data: assignment, isLoading: assignmentLoading } =
|
||||
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
|
||||
if (assignmentLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50 mb-3" />
|
||||
<p className="font-medium text-destructive">Assignment not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const project = assignment.project
|
||||
const evalStatus = assignment.evaluation?.status ?? 'NOT_STARTED'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{project.title}</h1>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
|
||||
{project.teamName && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{project.teamName}
|
||||
</span>
|
||||
)}
|
||||
{project.country && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{project.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project description */}
|
||||
{project.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Description</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
{project.tags.map((tag: string) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Evaluation status */}
|
||||
<EvalStatusCard
|
||||
status={evalStatus}
|
||||
stageId={stageId}
|
||||
projectId={projectId}
|
||||
isWindowOpen={isWindowOpen}
|
||||
/>
|
||||
|
||||
{/* Project files */}
|
||||
<CollapsibleFilesSection
|
||||
projectId={projectId}
|
||||
fileCount={project.files?.length ?? 0}
|
||||
stageId={stageId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,247 +1,247 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Target,
|
||||
Layers,
|
||||
} from 'lucide-react'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function JuryStagesDashboard() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id ?? ''
|
||||
|
||||
const { data: stages, isLoading: stagesLoading } =
|
||||
trpc.stageAssignment.myStages.useQuery(
|
||||
{ programId },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
const totalAssignments = stages?.reduce((sum, s) => sum + s.stats.total, 0) ?? 0
|
||||
const totalCompleted = stages?.reduce((sum, s) => sum + s.stats.completed, 0) ?? 0
|
||||
const totalInProgress = stages?.reduce((sum, s) => sum + s.stats.inProgress, 0) ?? 0
|
||||
const totalPending = totalAssignments - totalCompleted - totalInProgress
|
||||
const completionRate = totalAssignments > 0
|
||||
? Math.round((totalCompleted / totalAssignments) * 100)
|
||||
: 0
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Total Assignments',
|
||||
value: totalAssignments,
|
||||
icon: ClipboardList,
|
||||
accentColor: 'border-l-blue-500',
|
||||
iconBg: 'bg-blue-50 dark:bg-blue-950/40',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
},
|
||||
{
|
||||
label: 'Completed',
|
||||
value: totalCompleted,
|
||||
icon: CheckCircle2,
|
||||
accentColor: 'border-l-emerald-500',
|
||||
iconBg: 'bg-emerald-50 dark:bg-emerald-950/40',
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||
},
|
||||
{
|
||||
label: 'In Progress',
|
||||
value: totalInProgress,
|
||||
icon: Clock,
|
||||
accentColor: 'border-l-amber-500',
|
||||
iconBg: 'bg-amber-50 dark:bg-amber-950/40',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
},
|
||||
{
|
||||
label: 'Pending',
|
||||
value: totalPending,
|
||||
icon: Target,
|
||||
accentColor: 'border-l-slate-400',
|
||||
iconBg: 'bg-slate-50 dark:bg-slate-800/50',
|
||||
iconColor: 'text-slate-500 dark:text-slate-400',
|
||||
},
|
||||
]
|
||||
|
||||
if (stagesLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Card key={i} className="border-l-4 border-l-muted">
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<Skeleton className="h-11 w-11 rounded-xl" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-12" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="py-5">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stages || stages.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3">
|
||||
<Layers className="h-8 w-8 text-brand-teal/60" />
|
||||
</div>
|
||||
<p className="text-lg font-semibold">No stage assignments yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
|
||||
Your stage-based assignments will appear here once an administrator assigns projects to you.
|
||||
</p>
|
||||
<Button variant="outline" asChild className="mt-4">
|
||||
<Link href="/jury">
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{stats.map((stat) => (
|
||||
<Card
|
||||
key={stat.label}
|
||||
className={cn('border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', stat.accentColor)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<div className={cn('rounded-xl p-3', stat.iconBg)}>
|
||||
<stat.icon className={cn('h-5 w-5', stat.iconColor)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight">{stat.value}</p>
|
||||
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<Card className="border-l-4 border-l-brand-teal">
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<div className="rounded-xl p-3 bg-brand-blue/10 dark:bg-brand-blue/20">
|
||||
<BarChart3 className="h-5 w-5 text-brand-blue dark:text-brand-teal" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight text-brand-blue dark:text-brand-teal">
|
||||
{completionRate}%
|
||||
</p>
|
||||
<Progress value={completionRate} className="h-1.5 mt-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Stage cards */}
|
||||
<div className="space-y-3">
|
||||
{stages.map((stage) => {
|
||||
const stageProgress = stage.stats.total > 0
|
||||
? Math.round((stage.stats.completed / stage.stats.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Card key={stage.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="py-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-lg truncate">{stage.name}</h3>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stage.windowOpenAt}
|
||||
windowCloseAt={stage.windowCloseAt}
|
||||
status={stage.status}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{stage.track.name} · {stage.track.pipeline.name}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{stage.stats.completed}/{stage.stats.total}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={stageProgress} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:flex-col sm:items-end">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{stage.stats.completed > 0 && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{stage.stats.completed} done
|
||||
</Badge>
|
||||
)}
|
||||
{stage.stats.inProgress > 0 && (
|
||||
<Badge variant="warning" className="text-xs">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
{stage.stats.inProgress} in progress
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stage.id}/assignments` as Route}>
|
||||
View Assignments
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Target,
|
||||
Layers,
|
||||
} from 'lucide-react'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function JuryStagesDashboard() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id ?? ''
|
||||
|
||||
const { data: stages, isLoading: stagesLoading } =
|
||||
trpc.stageAssignment.myStages.useQuery(
|
||||
{ programId },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
const totalAssignments = stages?.reduce((sum, s) => sum + s.stats.total, 0) ?? 0
|
||||
const totalCompleted = stages?.reduce((sum, s) => sum + s.stats.completed, 0) ?? 0
|
||||
const totalInProgress = stages?.reduce((sum, s) => sum + s.stats.inProgress, 0) ?? 0
|
||||
const totalPending = totalAssignments - totalCompleted - totalInProgress
|
||||
const completionRate = totalAssignments > 0
|
||||
? Math.round((totalCompleted / totalAssignments) * 100)
|
||||
: 0
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Total Assignments',
|
||||
value: totalAssignments,
|
||||
icon: ClipboardList,
|
||||
accentColor: 'border-l-blue-500',
|
||||
iconBg: 'bg-blue-50 dark:bg-blue-950/40',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
},
|
||||
{
|
||||
label: 'Completed',
|
||||
value: totalCompleted,
|
||||
icon: CheckCircle2,
|
||||
accentColor: 'border-l-emerald-500',
|
||||
iconBg: 'bg-emerald-50 dark:bg-emerald-950/40',
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||
},
|
||||
{
|
||||
label: 'In Progress',
|
||||
value: totalInProgress,
|
||||
icon: Clock,
|
||||
accentColor: 'border-l-amber-500',
|
||||
iconBg: 'bg-amber-50 dark:bg-amber-950/40',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
},
|
||||
{
|
||||
label: 'Pending',
|
||||
value: totalPending,
|
||||
icon: Target,
|
||||
accentColor: 'border-l-slate-400',
|
||||
iconBg: 'bg-slate-50 dark:bg-slate-800/50',
|
||||
iconColor: 'text-slate-500 dark:text-slate-400',
|
||||
},
|
||||
]
|
||||
|
||||
if (stagesLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Card key={i} className="border-l-4 border-l-muted">
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<Skeleton className="h-11 w-11 rounded-xl" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-12" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="py-5">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stages || stages.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3">
|
||||
<Layers className="h-8 w-8 text-brand-teal/60" />
|
||||
</div>
|
||||
<p className="text-lg font-semibold">No stage assignments yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
|
||||
Your stage-based assignments will appear here once an administrator assigns projects to you.
|
||||
</p>
|
||||
<Button variant="outline" asChild className="mt-4">
|
||||
<Link href="/jury">
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{stats.map((stat) => (
|
||||
<Card
|
||||
key={stat.label}
|
||||
className={cn('border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', stat.accentColor)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<div className={cn('rounded-xl p-3', stat.iconBg)}>
|
||||
<stat.icon className={cn('h-5 w-5', stat.iconColor)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight">{stat.value}</p>
|
||||
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<Card className="border-l-4 border-l-brand-teal">
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<div className="rounded-xl p-3 bg-brand-blue/10 dark:bg-brand-blue/20">
|
||||
<BarChart3 className="h-5 w-5 text-brand-blue dark:text-brand-teal" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight text-brand-blue dark:text-brand-teal">
|
||||
{completionRate}%
|
||||
</p>
|
||||
<Progress value={completionRate} className="h-1.5 mt-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Stage cards */}
|
||||
<div className="space-y-3">
|
||||
{stages.map((stage) => {
|
||||
const stageProgress = stage.stats.total > 0
|
||||
? Math.round((stage.stats.completed / stage.stats.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Card key={stage.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="py-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-lg truncate">{stage.name}</h3>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stage.windowOpenAt}
|
||||
windowCloseAt={stage.windowCloseAt}
|
||||
status={stage.status}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{stage.track.name} · {stage.track.pipeline.name}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{stage.stats.completed}/{stage.stats.total}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={stageProgress} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:flex-col sm:items-end">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{stage.stats.completed > 0 && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{stage.stats.completed} done
|
||||
</Badge>
|
||||
)}
|
||||
{stage.stats.inProgress > 0 && (
|
||||
<Badge variant="warning" className="text-xs">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
{stage.stats.inProgress} in progress
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stage.id}/assignments` as Route}>
|
||||
View Assignments
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user