fix(applicant-feedback): correct scales, hide jury-internal criteria, declutter UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m11s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m11s
- globalScore is /10 (was hardcoded /100); use real round.name (was 'Round N') - Render criteria by type: numeric uses parsed scale (1-10/0-10/1-5), text shows as quoted block, boolean/advance hidden as jury-internal - Drop redundant cross-round stat strip and per-round Score Comparison - Plain language: 'Lowest/Highest' instead of 'Range', 'reviews' not 'evaluations' - Settings toggles update optimistically (was waiting for refresh) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,69 +8,72 @@ 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 { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import {
|
import { Star, MessageSquare, Trophy, Vote, ShieldCheck } from 'lucide-react'
|
||||||
Star,
|
|
||||||
MessageSquare,
|
|
||||||
Trophy,
|
|
||||||
Vote,
|
|
||||||
TrendingUp,
|
|
||||||
BarChart3,
|
|
||||||
Award,
|
|
||||||
ShieldCheck,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type Criterion = {
|
||||||
|
id?: string
|
||||||
|
type?: string
|
||||||
|
label?: string
|
||||||
|
name?: string
|
||||||
|
scale?: string
|
||||||
|
maxScore?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Evaluation = {
|
||||||
|
id: string
|
||||||
|
submittedAt: Date | null
|
||||||
|
globalScore: number | null
|
||||||
|
criterionScores: unknown
|
||||||
|
feedbackText: string | null
|
||||||
|
criteria: unknown
|
||||||
|
}
|
||||||
|
|
||||||
type EvaluationRound = {
|
type EvaluationRound = {
|
||||||
roundId: string
|
roundId: string
|
||||||
roundName: string
|
roundName: string
|
||||||
roundType: string
|
roundType: string
|
||||||
evaluationCount: number
|
evaluationCount: number
|
||||||
evaluations: Array<{
|
evaluations: Evaluation[]
|
||||||
id: string
|
|
||||||
submittedAt: Date | null
|
|
||||||
globalScore: number | null
|
|
||||||
criterionScores: unknown
|
|
||||||
feedbackText: string | null
|
|
||||||
criteria: unknown
|
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeRoundStats(round: EvaluationRound) {
|
const HIDDEN_CRITERION_TYPES = new Set(['boolean', 'advance'])
|
||||||
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100
|
|
||||||
|
function parseScaleMax(scale: string | undefined, fallback = 10): number {
|
||||||
|
if (!scale) return fallback
|
||||||
|
const m = scale.match(/^\s*\d+\s*-\s*(\d+)\s*$/)
|
||||||
|
if (m) return Number(m[1])
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCriterionMax(c: Criterion): number {
|
||||||
|
if (typeof c.maxScore === 'number' && c.maxScore > 0) return c.maxScore
|
||||||
|
return parseScaleMax(c.scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleCriteria(criteria: unknown): Criterion[] {
|
||||||
|
if (!Array.isArray(criteria)) return []
|
||||||
|
return (criteria as Criterion[]).filter((c) => {
|
||||||
|
if (!c) return false
|
||||||
|
if (!c.id && !c.label && !c.name) return false
|
||||||
|
if (c.type && HIDDEN_CRITERION_TYPES.has(c.type)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function globalScoreSummary(round: EvaluationRound) {
|
||||||
|
if (round.roundType === 'DELIBERATION') return null
|
||||||
const scores = round.evaluations
|
const scores = round.evaluations
|
||||||
.map((ev) => ev.globalScore)
|
.map((ev) => ev.globalScore)
|
||||||
.filter((s): s is number => s !== null)
|
.filter((s): s is number => s !== null)
|
||||||
const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null
|
if (scores.length === 0) return null
|
||||||
const highest = scores.length > 0 ? Math.max(...scores) : null
|
const max = 10
|
||||||
const lowest = scores.length > 0 ? Math.min(...scores) : null
|
const avg = scores.reduce((a, b) => a + b, 0) / scores.length
|
||||||
return { maxScore, avg, highest, lowest, scores }
|
const lowest = Math.min(...scores)
|
||||||
}
|
const highest = Math.max(...scores)
|
||||||
|
return { avg, lowest, highest, max }
|
||||||
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 }) {
|
function RoundIcon({ roundType, className }: { roundType: string; className?: string }) {
|
||||||
@@ -85,6 +88,48 @@ function roundIconBg(roundType: string) {
|
|||||||
return 'bg-yellow-500/10'
|
return 'bg-yellow-500/10'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CriterionBar({ value, max }: { value: number; max: number }) {
|
||||||
|
const pct = Math.max(0, Math.min(100, (value / max) * 100))
|
||||||
|
return (
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-brand-dark transition-all"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumericCriterion({ label, score, max }: { label: string; score: number | undefined; max: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-semibold tabular-nums">
|
||||||
|
{score !== undefined ? score : '—'}
|
||||||
|
<span className="text-muted-foreground font-normal text-xs"> / {max}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{score !== undefined && <CriterionBar value={score} max={max} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextCriterion({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<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 whitespace-pre-wrap">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ApplicantEvaluationsPage() {
|
export default function ApplicantEvaluationsPage() {
|
||||||
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
|
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
|
||||||
|
|
||||||
@@ -95,14 +140,6 @@ 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}>
|
||||||
@@ -119,34 +156,14 @@ 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>
|
||||||
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Anonymous evaluations from jury members
|
{hasEvaluations
|
||||||
|
? `Anonymous evaluations from jury members across ${rounds.length} round${rounds.length === 1 ? '' : 's'}.`
|
||||||
|
: 'Anonymous evaluations from jury members.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,174 +181,100 @@ export default function ApplicantEvaluationsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats Summary Strip */}
|
|
||||||
<AnimatedCard index={0}>
|
|
||||||
<Card className="p-0 overflow-hidden">
|
|
||||||
<div className="grid grid-cols-3 divide-x divide-border">
|
|
||||||
<div className="p-4 text-center">
|
|
||||||
<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) => {
|
{rounds.map((round, roundIdx) => {
|
||||||
const { maxScore, avg, highest, lowest } = computeRoundStats(round)
|
const summary = globalScoreSummary(round)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedCard key={round.roundId} index={roundIdx + 1}>
|
<AnimatedCard key={round.roundId} index={roundIdx}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<CardTitle className="flex items-center gap-2.5">
|
<CardTitle className="flex items-center gap-2.5">
|
||||||
<div className={cn('rounded-lg p-1.5', roundIconBg(round.roundType))}>
|
<div className={cn('rounded-lg p-1.5', roundIconBg(round.roundType))}>
|
||||||
<RoundIcon roundType={round.roundType} />
|
<RoundIcon roundType={round.roundType} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>{round.roundName}</span>
|
<span>{round.roundName}</span>
|
||||||
{avg !== null && round.roundType !== 'DELIBERATION' && (
|
{summary && (
|
||||||
<p className="text-sm font-normal text-muted-foreground mt-0.5">
|
<p className="text-sm font-normal text-muted-foreground mt-0.5">
|
||||||
Average: <span className="font-semibold text-foreground">{avg.toFixed(1)}</span> / {maxScore}
|
Average <span className="font-semibold text-foreground">{summary.avg.toFixed(1)}</span> / {summary.max}
|
||||||
{highest !== null && lowest !== null && highest !== lowest && (
|
{summary.lowest !== summary.highest && (
|
||||||
<span className="ml-2">
|
<span className="ml-2">· Lowest {summary.lowest} · Highest {summary.highest}</span>
|
||||||
Range: {lowest}–{highest}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary" className="shrink-0">
|
||||||
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'evaluation'}{round.evaluationCount !== 1 ? 's' : ''}
|
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'review'}{round.evaluationCount !== 1 ? 's' : ''}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{/* 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">
|
<CardContent className="p-0">
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{round.evaluations.map((ev, idx) => (
|
{round.evaluations.map((ev, idx) => {
|
||||||
<div
|
const criteria = visibleCriteria(ev.criteria)
|
||||||
key={ev.id}
|
const scores = (ev.criterionScores ?? {}) as Record<string, unknown>
|
||||||
className="px-6 py-4 space-y-3"
|
|
||||||
>
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div key={ev.id} className="px-6 py-4 space-y-4">
|
||||||
<span className="font-medium text-sm">
|
<div className="flex items-center justify-between">
|
||||||
{round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
|
<span className="font-medium text-sm">
|
||||||
</span>
|
{round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
|
||||||
<div className="flex items-center gap-3">
|
</span>
|
||||||
{ev.globalScore !== null && round.roundType !== 'DELIBERATION' && (
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex items-center gap-1">
|
{ev.globalScore !== null && round.roundType !== 'DELIBERATION' && (
|
||||||
<Star className="h-3.5 w-3.5 text-yellow-500" />
|
<span className="flex items-center gap-1">
|
||||||
<span className="text-sm font-bold tabular-nums">{ev.globalScore}</span>
|
<Star className="h-3.5 w-3.5 text-yellow-500" />
|
||||||
<span className="text-xs text-muted-foreground">/ {maxScore}</span>
|
<span className="text-sm font-bold tabular-nums">{ev.globalScore}</span>
|
||||||
</span>
|
<span className="text-xs text-muted-foreground">/ 10</span>
|
||||||
)}
|
</span>
|
||||||
{ev.submittedAt && (
|
)}
|
||||||
<span className="text-xs text-muted-foreground">
|
{ev.submittedAt && (
|
||||||
{new Date(ev.submittedAt).toLocaleDateString()}
|
<span className="text-xs text-muted-foreground">
|
||||||
</span>
|
{new Date(ev.submittedAt).toLocaleDateString()}
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{criteria.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{criteria.map((c, ci) => {
|
||||||
|
const key = c.id || String(ci)
|
||||||
|
const label = c.label || c.name || `Criterion ${ci + 1}`
|
||||||
|
const raw = scores[key]
|
||||||
|
|
||||||
|
if (c.type === 'text') {
|
||||||
|
if (typeof raw !== 'string' || raw.trim() === '') return null
|
||||||
|
return <TextCriterion key={key} label={label} value={raw} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// numeric (default)
|
||||||
|
const score = typeof raw === 'number' ? raw : undefined
|
||||||
|
const max = getCriterionMax(c)
|
||||||
|
return <NumericCriterion key={key} label={label} score={score} max={max} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ev.feedbackText && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<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" />
|
||||||
|
{round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'}
|
||||||
|
</div>
|
||||||
|
<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 whitespace-pre-wrap">
|
||||||
|
{ev.feedbackText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
{ev.criterionScores && ev.criteria && (
|
})}
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Criteria Breakdown</p>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{(() => {
|
|
||||||
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
|
|
||||||
const scores = ev.criterionScores as Record<string, number>
|
|
||||||
return criteria
|
|
||||||
.filter((c) => c.id || c.label || c.name)
|
|
||||||
.map((c, ci) => {
|
|
||||||
const key = c.id || String(ci)
|
|
||||||
const score = scores[key]
|
|
||||||
const cMax = c.maxScore || 10
|
|
||||||
const pct = score !== undefined ? (score / cMax) * 100 : 0
|
|
||||||
return (
|
|
||||||
<div key={ci} className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">{c.label || c.name || `Criterion ${ci + 1}`}</span>
|
|
||||||
<span className="font-semibold tabular-nums">
|
|
||||||
{score !== undefined ? score : '—'}
|
|
||||||
<span className="text-muted-foreground font-normal text-xs"> / {cMax}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{score !== undefined && (
|
|
||||||
<Progress value={pct} className="h-1.5" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ev.feedbackText && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<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" />
|
|
||||||
{round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'}
|
|
||||||
</div>
|
|
||||||
<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}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -339,7 +282,6 @@ export default function ApplicantEvaluationsPage() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Confidentiality Footer */}
|
|
||||||
<div className="flex items-center justify-center gap-2 py-2">
|
<div className="flex items-center justify-center gap-2 py-2">
|
||||||
<ShieldCheck className="h-4 w-4 text-muted-foreground/60" />
|
<ShieldCheck className="h-4 w-4 text-muted-foreground/60" />
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -624,8 +624,9 @@ function SettingToggle({
|
|||||||
settingKey: string
|
settingKey: string
|
||||||
value: string
|
value: string
|
||||||
}) {
|
}) {
|
||||||
|
const [optimistic, setOptimistic] = useState<boolean | null>(null)
|
||||||
const mutation = useSettingsMutation()
|
const mutation = useSettingsMutation()
|
||||||
const isChecked = value === 'true'
|
const isChecked = optimistic ?? value === 'true'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||||
@@ -638,9 +639,13 @@ function SettingToggle({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) => {
|
||||||
mutation.mutate({ key: settingKey, value: String(checked) })
|
setOptimistic(checked)
|
||||||
}
|
mutation.mutate(
|
||||||
|
{ key: settingKey, value: String(checked) },
|
||||||
|
{ onError: () => setOptimistic(null) },
|
||||||
|
)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1949,7 +1949,7 @@ export const applicantRouter = router({
|
|||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
roundId: round.id,
|
roundId: round.id,
|
||||||
roundName: `Evaluation Round ${i + 1}`,
|
roundName: round.name,
|
||||||
roundType: 'EVALUATION',
|
roundType: 'EVALUATION',
|
||||||
evaluationCount: evaluations.length,
|
evaluationCount: evaluations.length,
|
||||||
evaluations: evaluations.map((ev) => ({
|
evaluations: evaluations.map((ev) => ({
|
||||||
@@ -1997,13 +1997,8 @@ export const applicantRouter = router({
|
|||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
})
|
})
|
||||||
|
|
||||||
let evalCounter = 0
|
|
||||||
let liveFinalCounter = 0
|
|
||||||
let deliberationCounter = 0
|
|
||||||
|
|
||||||
for (const round of rounds) {
|
for (const round of rounds) {
|
||||||
if (round.roundType === 'EVALUATION') {
|
if (round.roundType === 'EVALUATION') {
|
||||||
evalCounter++
|
|
||||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||||
where: {
|
where: {
|
||||||
assignment: { projectId: project.id, roundId: round.id },
|
assignment: { projectId: project.id, roundId: round.id },
|
||||||
@@ -2023,7 +2018,7 @@ export const applicantRouter = router({
|
|||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
roundId: round.id,
|
roundId: round.id,
|
||||||
roundName: `Evaluation Round ${evalCounter}`,
|
roundName: round.name,
|
||||||
roundType: 'EVALUATION',
|
roundType: 'EVALUATION',
|
||||||
evaluationCount: evaluations.length,
|
evaluationCount: evaluations.length,
|
||||||
evaluations: evaluations.map((ev) => ({
|
evaluations: evaluations.map((ev) => ({
|
||||||
@@ -2036,7 +2031,6 @@ export const applicantRouter = router({
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
} else if (round.roundType === 'LIVE_FINAL') {
|
} else if (round.roundType === 'LIVE_FINAL') {
|
||||||
liveFinalCounter++
|
|
||||||
// LiveVote scores — anonymized
|
// LiveVote scores — anonymized
|
||||||
// Only show jury votes, not audience votes
|
// Only show jury votes, not audience votes
|
||||||
const votes = await ctx.prisma.liveVote.findMany({
|
const votes = await ctx.prisma.liveVote.findMany({
|
||||||
@@ -2056,7 +2050,7 @@ export const applicantRouter = router({
|
|||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
roundId: round.id,
|
roundId: round.id,
|
||||||
roundName: `Live Final ${liveFinalCounter}`,
|
roundName: round.name,
|
||||||
roundType: 'LIVE_FINAL',
|
roundType: 'LIVE_FINAL',
|
||||||
evaluationCount: votes.length,
|
evaluationCount: votes.length,
|
||||||
evaluations: votes.map((v) => ({
|
evaluations: votes.map((v) => ({
|
||||||
@@ -2069,7 +2063,6 @@ export const applicantRouter = router({
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
} else if (round.roundType === 'DELIBERATION') {
|
} else if (round.roundType === 'DELIBERATION') {
|
||||||
deliberationCounter++
|
|
||||||
// DeliberationVote — per-juror votes for this project
|
// DeliberationVote — per-juror votes for this project
|
||||||
const votes = await ctx.prisma.deliberationVote.findMany({
|
const votes = await ctx.prisma.deliberationVote.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -2089,7 +2082,7 @@ export const applicantRouter = router({
|
|||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
roundId: round.id,
|
roundId: round.id,
|
||||||
roundName: `Deliberation ${deliberationCounter}`,
|
roundName: round.name,
|
||||||
roundType: 'DELIBERATION',
|
roundType: 'DELIBERATION',
|
||||||
evaluationCount: votes.length,
|
evaluationCount: votes.length,
|
||||||
evaluations: votes.map((v) => ({
|
evaluations: votes.map((v) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user