feat: revamp applicant jury feedback UI with score summaries and observer-style design
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

Dashboard sidebar card now shows per-round avg score with progress bars.
Evaluations page redesigned with stats strip, score comparison bars,
criteria progress bars, animated cards, and styled feedback blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 10:16:37 +01:00
parent 6852278f92
commit 0d9a985377
2 changed files with 329 additions and 99 deletions

View File

@@ -8,8 +8,82 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Star, MessageSquare, Trophy, Vote } from 'lucide-react' import { AnimatedCard } from '@/components/shared/animated-container'
import {
Star,
MessageSquare,
Trophy,
Vote,
TrendingUp,
BarChart3,
Award,
ShieldCheck,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type EvaluationRound = {
roundId: string
roundName: string
roundType: string
evaluationCount: number
evaluations: Array<{
id: string
submittedAt: Date | null
globalScore: number | null
criterionScores: unknown
feedbackText: string | null
criteria: unknown
}>
}
function computeRoundStats(round: EvaluationRound) {
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
const scores = round.evaluations
.map((ev) => ev.globalScore)
.filter((s): s is number => s !== null)
const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null
const highest = scores.length > 0 ? Math.max(...scores) : null
const lowest = scores.length > 0 ? Math.min(...scores) : null
return { maxScore, avg, highest, lowest, scores }
}
function ScoreBar({ score, maxScore, color }: { score: number; maxScore: number; color: string }) {
const pct = (score / maxScore) * 100
return (
<div className="flex items-center gap-2 flex-1">
<div className="flex-1 overflow-hidden rounded-full bg-muted" style={{ height: 10 }}>
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: color }}
/>
</div>
<span className="text-sm font-semibold tabular-nums w-8 text-right">{score}</span>
</div>
)
}
function getScoreColor(score: number, maxScore: number): string {
const pct = score / maxScore
if (pct >= 0.8) return '#053d57'
if (pct >= 0.6) return '#1e7a8a'
if (pct >= 0.4) return '#557f8c'
if (pct >= 0.2) return '#c4453a'
return '#de0f1e'
}
function RoundIcon({ roundType, className }: { roundType: string; className?: string }) {
if (roundType === 'LIVE_FINAL') return <Trophy className={cn('h-4 w-4 text-amber-500', className)} />
if (roundType === 'DELIBERATION') return <Vote className={cn('h-4 w-4 text-violet-500', className)} />
return <Star className={cn('h-4 w-4 text-yellow-500', className)} />
}
function roundIconBg(roundType: string) {
if (roundType === 'LIVE_FINAL') return 'bg-amber-500/10'
if (roundType === 'DELIBERATION') return 'bg-violet-500/10'
return 'bg-yellow-500/10'
}
export default function ApplicantEvaluationsPage() { export default function ApplicantEvaluationsPage() {
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery() const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
@@ -21,6 +95,14 @@ export default function ApplicantEvaluationsPage() {
<h1 className="text-2xl font-bold">Jury Feedback</h1> <h1 className="text-2xl font-bold">Jury Feedback</h1>
<p className="text-muted-foreground">Anonymous evaluations from jury members</p> <p className="text-muted-foreground">Anonymous evaluations from jury members</p>
</div> </div>
<div className="grid grid-cols-3 gap-px overflow-hidden rounded-lg border bg-border">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-card p-4">
<Skeleton className="h-5 w-20 mb-2" />
<Skeleton className="h-8 w-16" />
</div>
))}
</div>
<div className="space-y-4"> <div className="space-y-4">
{[1, 2].map((i) => ( {[1, 2].map((i) => (
<Card key={i}> <Card key={i}>
@@ -37,6 +119,28 @@ export default function ApplicantEvaluationsPage() {
const hasEvaluations = rounds && rounds.length > 0 const hasEvaluations = rounds && rounds.length > 0
// Compute global stats
const allScores: number[] = []
let totalEvaluations = 0
if (rounds) {
for (const round of rounds) {
totalEvaluations += round.evaluationCount
for (const ev of round.evaluations) {
if (ev.globalScore !== null && round.roundType !== 'DELIBERATION') {
// Normalize to 0-100 for live final scores
const normalized = round.roundType === 'LIVE_FINAL'
? ev.globalScore * 10
: ev.globalScore
allScores.push(normalized)
}
}
}
}
const globalAvg = allScores.length > 0
? allScores.reduce((a, b) => a + b, 0) / allScores.length
: null
const globalHighest = allScores.length > 0 ? Math.max(...allScores) : null
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
@@ -49,7 +153,9 @@ export default function ApplicantEvaluationsPage() {
{!hasEvaluations ? ( {!hasEvaluations ? (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<Star className="h-12 w-12 text-muted-foreground/50 mb-4" /> <div className="rounded-2xl bg-muted/60 p-4 mb-4">
<Star className="h-8 w-8 text-muted-foreground/50" />
</div>
<h3 className="text-lg font-medium mb-2">No Evaluations Available</h3> <h3 className="text-lg font-medium mb-2">No Evaluations Available</h3>
<p className="text-muted-foreground text-center max-w-md"> <p className="text-muted-foreground text-center max-w-md">
Evaluations will appear here once jury review is complete and results are published. Evaluations will appear here once jury review is complete and results are published.
@@ -58,56 +164,128 @@ export default function ApplicantEvaluationsPage() {
</Card> </Card>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
{rounds.map((round) => { {/* Stats Summary Strip */}
const roundIcon = round.roundType === 'LIVE_FINAL' <AnimatedCard index={0}>
? <Trophy className="h-5 w-5 text-amber-500" /> <Card className="p-0 overflow-hidden">
: round.roundType === 'DELIBERATION' <div className="grid grid-cols-3 divide-x divide-border">
? <Vote className="h-5 w-5 text-violet-500" /> <div className="p-4 text-center">
: <Star className="h-5 w-5 text-yellow-500" /> <div className="flex items-center justify-center gap-1.5 mb-1">
<BarChart3 className="h-3.5 w-3.5 text-blue-500" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Reviews</span>
</div>
<p className="text-2xl font-bold tabular-nums">{totalEvaluations}</p>
</div>
<div className="p-4 text-center">
<div className="flex items-center justify-center gap-1.5 mb-1">
<TrendingUp className="h-3.5 w-3.5 text-emerald-500" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Avg Score</span>
</div>
<p className="text-2xl font-bold tabular-nums">
{globalAvg !== null ? globalAvg.toFixed(1) : '—'}
{globalAvg !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
</p>
</div>
<div className="p-4 text-center">
<div className="flex items-center justify-center gap-1.5 mb-1">
<Award className="h-3.5 w-3.5 text-amber-500" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Highest</span>
</div>
<p className="text-2xl font-bold tabular-nums">
{globalHighest !== null ? globalHighest : '—'}
{globalHighest !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
</p>
</div>
</div>
</Card>
</AnimatedCard>
{/* Per-Round Cards */}
{rounds.map((round, roundIdx) => {
const { maxScore, avg, highest, lowest } = computeRoundStats(round)
return ( return (
<Card key={round.roundId}> <AnimatedCard key={round.roundId} index={roundIdx + 1}>
<CardHeader> <Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2.5">
{roundIcon} <div className={cn('rounded-lg p-1.5', roundIconBg(round.roundType))}>
{round.roundName} <RoundIcon roundType={round.roundType} />
</div>
<div>
<span>{round.roundName}</span>
{avg !== null && round.roundType !== 'DELIBERATION' && (
<p className="text-sm font-normal text-muted-foreground mt-0.5">
Average: <span className="font-semibold text-foreground">{avg.toFixed(1)}</span> / {maxScore}
{highest !== null && lowest !== null && highest !== lowest && (
<span className="ml-2">
Range: {lowest}{highest}
</span>
)}
</p>
)}
</div>
</CardTitle> </CardTitle>
<Badge variant="secondary"> <Badge variant="secondary">
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'evaluation'}{round.evaluationCount !== 1 ? 's' : ''} {round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'evaluation'}{round.evaluationCount !== 1 ? 's' : ''}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4">
{/* Score Overview Bar — visual comparison across evaluators */}
{round.roundType !== 'DELIBERATION' && round.evaluations.some((ev) => ev.globalScore !== null) && (
<div className="px-6 pb-3">
<div className="rounded-lg bg-muted/40 p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Score Comparison</p>
{round.evaluations.map((ev, idx) => {
if (ev.globalScore === null) return null
return (
<div key={ev.id} className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-6 text-right shrink-0 tabular-nums">
#{idx + 1}
</span>
<ScoreBar
score={ev.globalScore}
maxScore={maxScore}
color={getScoreColor(ev.globalScore, maxScore)}
/>
</div>
)
})}
</div>
</div>
)}
<CardContent className="p-0">
<div className="divide-y">
{round.evaluations.map((ev, idx) => ( {round.evaluations.map((ev, idx) => (
<div <div
key={ev.id} key={ev.id}
className="rounded-lg border p-4 space-y-3" className="px-6 py-4 space-y-3"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium text-sm"> <span className="font-medium text-sm">
{round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`} {round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
</span> </span>
<div className="flex items-center gap-3">
{ev.globalScore !== null && round.roundType !== 'DELIBERATION' && (
<span className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 text-yellow-500" />
<span className="text-sm font-bold tabular-nums">{ev.globalScore}</span>
<span className="text-xs text-muted-foreground">/ {maxScore}</span>
</span>
)}
{ev.submittedAt && ( {ev.submittedAt && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{new Date(ev.submittedAt).toLocaleDateString()} {new Date(ev.submittedAt).toLocaleDateString()}
</span> </span>
)} )}
</div> </div>
{ev.globalScore !== null && round.roundType !== 'DELIBERATION' && (
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-yellow-500" />
<span className="text-lg font-semibold">{ev.globalScore}</span>
<span className="text-sm text-muted-foreground">
/ {round.roundType === 'LIVE_FINAL' ? '10' : '100'}
</span>
</div> </div>
)}
{ev.criterionScores && ev.criteria && ( {ev.criterionScores && ev.criteria && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Criterion Scores</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Criteria Breakdown</p>
<div className="grid gap-2"> <div className="grid gap-2">
{(() => { {(() => {
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }> const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
@@ -117,14 +295,21 @@ export default function ApplicantEvaluationsPage() {
.map((c, ci) => { .map((c, ci) => {
const key = c.id || String(ci) const key = c.id || String(ci)
const score = scores[key] const score = scores[key]
const cMax = c.maxScore || 10
const pct = score !== undefined ? (score / cMax) * 100 : 0
return ( return (
<div key={ci} className="flex items-center justify-between text-sm"> <div key={ci} className="space-y-1">
<span>{c.label || c.name || `Criterion ${ci + 1}`}</span> <div className="flex items-center justify-between text-sm">
<span className="font-medium"> <span className="text-muted-foreground">{c.label || c.name || `Criterion ${ci + 1}`}</span>
<span className="font-semibold tabular-nums">
{score !== undefined ? score : '—'} {score !== undefined ? score : '—'}
{c.maxScore ? ` / ${c.maxScore}` : ''} <span className="text-muted-foreground font-normal text-xs"> / {cMax}</span>
</span> </span>
</div> </div>
{score !== undefined && (
<Progress value={pct} className="h-1.5" />
)}
</div>
) )
}) })
})()} })()}
@@ -134,26 +319,34 @@ export default function ApplicantEvaluationsPage() {
{ev.feedbackText && ( {ev.feedbackText && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">
<MessageSquare className="h-3.5 w-3.5" /> <MessageSquare className="h-3.5 w-3.5" />
{round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'} {round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'}
</div> </div>
<blockquote className="border-l-2 border-muted pl-4 text-sm italic text-muted-foreground"> <div className="rounded-lg bg-muted/40 px-4 py-3 border-l-3 border-brand-teal">
<p className="text-sm italic text-muted-foreground leading-relaxed">
{ev.feedbackText} {ev.feedbackText}
</blockquote> </p>
</div>
</div> </div>
)} )}
</div> </div>
))} ))}
</div>
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
) )
})} })}
<p className="text-xs text-muted-foreground text-center"> {/* Confidentiality Footer */}
<div className="flex items-center justify-center gap-2 py-2">
<ShieldCheck className="h-4 w-4 text-muted-foreground/60" />
<p className="text-xs text-muted-foreground">
Evaluator identities are kept confidential. Evaluator identities are kept confidential.
</p> </p>
</div> </div>
</div>
)} )}
</div> </div>
) )

View File

@@ -19,6 +19,7 @@ import { CompetitionTimelineSidebar } from '@/components/applicant/competition-t
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card' import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload' import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
import { Progress } from '@/components/ui/progress'
import { import {
FileText, FileText,
Calendar, Calendar,
@@ -35,6 +36,8 @@ import {
Check, Check,
X, X,
UserCircle, UserCircle,
Trophy,
Vote,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -390,7 +393,9 @@ export default function ApplicantDashboardPage() {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Star className="h-5 w-5" /> <div className="rounded-lg bg-yellow-500/10 p-1.5">
<Star className="h-4 w-4 text-yellow-500" />
</div>
Jury Feedback Jury Feedback
</CardTitle> </CardTitle>
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild>
@@ -400,15 +405,47 @@ export default function ApplicantDashboardPage() {
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-3">
{evaluations?.map((round) => ( {evaluations?.map((round) => {
<div key={round.roundId} className="flex items-center justify-between text-sm rounded-lg border p-2.5"> const scores = round.evaluations
<span className="font-medium">{round.roundName}</span> .map((ev) => ev.globalScore)
.filter((s): s is number => s !== null)
const avgScore = scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: null
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
const pct = avgScore !== null ? (avgScore / maxScore) * 100 : 0
const roundIcon = round.roundType === 'LIVE_FINAL'
? <Trophy className="h-3.5 w-3.5 text-amber-500" />
: round.roundType === 'DELIBERATION'
? <Vote className="h-3.5 w-3.5 text-violet-500" />
: <Star className="h-3.5 w-3.5 text-yellow-500" />
return (
<div key={round.roundId} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium">
{roundIcon}
{round.roundName}
</span>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{round.evaluationCount} review{round.evaluationCount !== 1 ? 's' : ''} {round.evaluationCount} review{round.evaluationCount !== 1 ? 's' : ''}
</Badge> </Badge>
</div> </div>
))} {avgScore !== null && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Avg Score</span>
<span className="font-semibold text-foreground tabular-nums">
{avgScore.toFixed(1)}<span className="text-muted-foreground font-normal"> / {maxScore}</span>
</span>
</div>
<Progress value={pct} className="h-1.5" />
</div>
)}
</div>
)
})}
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard> </AnimatedCard>