Jury evaluation UX overhaul + admin review features
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s

- Fix project documents not displaying on jury project page (rewrote MultiWindowDocViewer to use file.listByProject)
- Add working download/preview for project files via presigned URLs
- Display project tags on jury project detail page
- Add autosave for evaluation drafts (debounced 3s + save on unmount/beforeunload)
- Support mixed criterion types: numeric scores, yes/no booleans, text responses, section headers
- Replace inline criteria editor with rich EvaluationFormBuilder on admin round page
- Remove COI dialog from evaluation page
- Update AI summary service to handle boolean/text criteria (yes/no counts, text synthesis)
- Update EvaluationSummaryCard to show boolean criteria bars and text responses
- Add evaluation detail sheet on admin project page (click juror row to view full scores + feedback)
- Add Recent Evaluations dashboard widget showing latest jury reviews

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-18 12:43:28 +01:00
parent 73759eaddd
commit 9ce56f13fd
12 changed files with 1137 additions and 385 deletions

View File

@@ -22,6 +22,7 @@ import { ProjectListCompact } from '@/components/dashboard/project-list-compact'
import { ActivityFeed } from '@/components/dashboard/activity-feed' import { ActivityFeed } from '@/components/dashboard/activity-feed'
import { CategoryBreakdown } from '@/components/dashboard/category-breakdown' import { CategoryBreakdown } from '@/components/dashboard/category-breakdown'
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton' import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton'
import { RecentEvaluations } from '@/components/dashboard/recent-evaluations'
type DashboardContentProps = { type DashboardContentProps = {
editionId: string editionId: string
@@ -33,6 +34,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{ editionId }, { editionId },
{ enabled: !!editionId, retry: 1, refetchInterval: 30_000 } { enabled: !!editionId, retry: 1, refetchInterval: 30_000 }
) )
const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery(
{ editionId, limit: 8 },
{ enabled: !!editionId, refetchInterval: 30_000 }
)
if (isLoading) { if (isLoading) {
return <DashboardSkeleton /> return <DashboardSkeleton />
@@ -158,15 +163,21 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<AnimatedCard index={3}> <AnimatedCard index={3}>
<ProjectListCompact projects={latestProjects} /> <ProjectListCompact projects={latestProjects} />
</AnimatedCard> </AnimatedCard>
{recentEvals && recentEvals.length > 0 && (
<AnimatedCard index={4}>
<RecentEvaluations evaluations={recentEvals} />
</AnimatedCard>
)}
</div> </div>
{/* Right Column */} {/* Right Column */}
<div className="space-y-6 lg:col-span-4"> <div className="space-y-6 lg:col-span-4">
<AnimatedCard index={4}> <AnimatedCard index={5}>
<SmartActions actions={nextActions} /> <SmartActions actions={nextActions} />
</AnimatedCard> </AnimatedCard>
<AnimatedCard index={5}> <AnimatedCard index={6}>
<ActivityFeed activity={recentActivity} /> <ActivityFeed activity={recentActivity} />
</AnimatedCard> </AnimatedCard>
</div> </div>
@@ -175,12 +186,12 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{/* Bottom Full Width */} {/* Bottom Full Width */}
<div className="grid gap-6 lg:grid-cols-12"> <div className="grid gap-6 lg:grid-cols-12">
<div className="lg:col-span-8"> <div className="lg:col-span-8">
<AnimatedCard index={6}> <AnimatedCard index={7}>
<GeographicSummaryCard programId={editionId} /> <GeographicSummaryCard programId={editionId} />
</AnimatedCard> </AnimatedCard>
</div> </div>
<div className="lg:col-span-4"> <div className="lg:col-span-4">
<AnimatedCard index={7}> <AnimatedCard index={8}>
<CategoryBreakdown <CategoryBreakdown
categories={categoryBreakdown} categories={categoryBreakdown}
issues={oceanIssueBreakdown} issues={oceanIssueBreakdown}

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { Suspense, use } from 'react' import { Suspense, use, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
@@ -28,6 +28,13 @@ import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import { UserAvatar } from '@/components/shared/user-avatar' import { UserAvatar } from '@/components/shared/user-avatar'
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card' import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { import {
ArrowLeft, ArrowLeft,
@@ -51,6 +58,8 @@ import {
UserPlus, UserPlus,
Loader2, Loader2,
ScanSearch, ScanSearch,
Eye,
MessageSquare,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatDate, formatDateOnly } from '@/lib/utils' import { formatDate, formatDateOnly } from '@/lib/utils'
@@ -117,6 +126,10 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
const utils = trpc.useUtils() const utils = trpc.useUtils()
// State for evaluation detail sheet
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
if (isLoading) { if (isLoading) {
return <ProjectDetailSkeleton /> return <ProjectDetailSkeleton />
} }
@@ -728,11 +741,20 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Score</TableHead> <TableHead>Score</TableHead>
<TableHead>Decision</TableHead> <TableHead>Decision</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{assignments.map((assignment) => ( {assignments.map((assignment) => (
<TableRow key={assignment.id}> <TableRow
key={assignment.id}
className={assignment.evaluation?.status === 'SUBMITTED' ? 'cursor-pointer hover:bg-muted/50' : ''}
onClick={() => {
if (assignment.evaluation?.status === 'SUBMITTED') {
setSelectedEvalAssignment(assignment)
}
}}
>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UserAvatar <UserAvatar
@@ -806,6 +828,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<span className="text-muted-foreground">-</span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell>
{assignment.evaluation?.status === 'SUBMITTED' && (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -815,6 +842,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</AnimatedCard> </AnimatedCard>
)} )}
{/* Evaluation Detail Sheet */}
<EvaluationDetailSheet
assignment={selectedEvalAssignment}
open={!!selectedEvalAssignment}
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
/>
{/* AI Evaluation Summary */} {/* AI Evaluation Summary */}
{assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && ( {assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && (
<EvaluationSummaryCard <EvaluationSummaryCard
@@ -897,6 +931,173 @@ function AnalyzeDocumentsButton({ projectId, onComplete }: { projectId: string;
) )
} }
function EvaluationDetailSheet({
assignment,
open,
onOpenChange,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assignment: any
open: boolean
onOpenChange: (open: boolean) => void
}) {
if (!assignment?.evaluation) return null
const ev = assignment.evaluation
const criterionScores = (ev.criterionScoresJson || {}) as Record<string, number | boolean | string>
const hasScores = Object.keys(criterionScores).length > 0
// Try to get the evaluation form for labels
const roundId = assignment.roundId as string | undefined
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId: roundId ?? '' },
{ enabled: !!roundId }
)
// Build label lookup from form criteria
const criteriaMap = new Map<string, { label: string; type: string; trueLabel?: string; falseLabel?: string }>()
if (activeForm?.criteriaJson) {
for (const c of activeForm.criteriaJson as Array<{ id: string; label: string; type?: string; trueLabel?: string; falseLabel?: string }>) {
criteriaMap.set(c.id, {
label: c.label,
type: c.type || 'numeric',
trueLabel: c.trueLabel,
falseLabel: c.falseLabel,
})
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<UserAvatar user={assignment.user} avatarUrl={assignment.user.avatarUrl} size="sm" />
{assignment.user.name || assignment.user.email}
</SheetTitle>
<SheetDescription>
{ev.submittedAt
? `Submitted ${formatDate(ev.submittedAt)}`
: 'Evaluation details'}
</SheetDescription>
</SheetHeader>
<div className="space-y-6 mt-6">
{/* Global stats */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted">
<p className="text-xs text-muted-foreground">Score</p>
<p className="text-2xl font-bold">
{ev.globalScore !== null ? `${ev.globalScore}/10` : '-'}
</p>
</div>
<div className="p-3 rounded-lg bg-muted">
<p className="text-xs text-muted-foreground">Decision</p>
<div className="mt-1">
{ev.binaryDecision !== null ? (
ev.binaryDecision ? (
<div className="flex items-center gap-1.5 text-emerald-600">
<ThumbsUp className="h-5 w-5" />
<span className="font-semibold">Yes</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-red-600">
<ThumbsDown className="h-5 w-5" />
<span className="font-semibold">No</span>
</div>
)
) : (
<span className="text-2xl font-bold">-</span>
)}
</div>
</div>
</div>
{/* Criterion Scores */}
{hasScores && (
<div>
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Criterion Scores
</h4>
<div className="space-y-2.5">
{Object.entries(criterionScores).map(([key, value]) => {
const meta = criteriaMap.get(key)
const label = meta?.label || key
const type = meta?.type || (typeof value === 'boolean' ? 'boolean' : typeof value === 'string' ? 'text' : 'numeric')
if (type === 'section_header') return null
if (type === 'boolean') {
return (
<div key={key} className="flex items-center justify-between p-2.5 rounded-lg border">
<span className="text-sm">{label}</span>
{value === true ? (
<Badge className="bg-emerald-100 text-emerald-700 border-emerald-200" variant="outline">
<ThumbsUp className="mr-1 h-3 w-3" />
{meta?.trueLabel || 'Yes'}
</Badge>
) : (
<Badge className="bg-red-100 text-red-700 border-red-200" variant="outline">
<ThumbsDown className="mr-1 h-3 w-3" />
{meta?.falseLabel || 'No'}
</Badge>
)}
</div>
)
}
if (type === 'text') {
return (
<div key={key} className="space-y-1">
<span className="text-sm font-medium">{label}</span>
<div className="text-sm text-muted-foreground p-2.5 rounded-lg border bg-muted/50 whitespace-pre-wrap">
{typeof value === 'string' ? value : String(value)}
</div>
</div>
)
}
// Numeric
return (
<div key={key} className="flex items-center gap-3 p-2.5 rounded-lg border">
<span className="text-sm flex-1 truncate">{label}</span>
<div className="flex items-center gap-2 shrink-0">
<div className="w-20 h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${(Number(value) / 10) * 100}%` }}
/>
</div>
<span className="text-sm font-bold tabular-nums w-8 text-right">
{typeof value === 'number' ? value : '-'}
</span>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Feedback Text */}
{ev.feedbackText && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Feedback
</h4>
<div className="text-sm text-muted-foreground p-3 rounded-lg border bg-muted/30 whitespace-pre-wrap leading-relaxed">
{ev.feedbackText}
</div>
</div>
)}
</div>
</SheetContent>
</Sheet>
)
}
export default function ProjectDetailPage({ params }: PageProps) { export default function ProjectDetailPage({ params }: PageProps) {
const { id } = use(params) const { id } = use(params)

View File

@@ -108,6 +108,8 @@ import { AnimatedCard } from '@/components/shared/animated-container'
import { DateTimePicker } from '@/components/ui/datetime-picker' import { DateTimePicker } from '@/components/ui/datetime-picker'
import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog' import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog'
import { motion } from 'motion/react' import { motion } from 'motion/react'
import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder'
import type { Criterion } from '@/components/forms/evaluation-form-builder'
// ── Status & type config maps ────────────────────────────────────────────── // ── Status & type config maps ──────────────────────────────────────────────
const roundStatusConfig = { const roundStatusConfig = {
@@ -3118,12 +3120,9 @@ function AIRecommendationsDisplay({
// ── Evaluation Criteria Editor ─────────────────────────────────────────── // ── Evaluation Criteria Editor ───────────────────────────────────────────
function EvaluationCriteriaEditor({ roundId }: { roundId: string }) { function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
const [editing, setEditing] = useState(false) const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
const [criteria, setCriteria] = useState<Array<{
id: string; label: string; description?: string; weight?: number; minScore?: number; maxScore?: number
}>>([])
const utils = trpc.useUtils() const utils = trpc.useUtils()
const { data: form, isLoading } = trpc.evaluation.getForm.useQuery( const { data: form, isLoading } = trpc.evaluation.getForm.useQuery(
{ roundId }, { roundId },
{ refetchInterval: 30_000 }, { refetchInterval: 30_000 },
@@ -3133,49 +3132,59 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
onSuccess: () => { onSuccess: () => {
utils.evaluation.getForm.invalidate({ roundId }) utils.evaluation.getForm.invalidate({ roundId })
toast.success('Evaluation criteria saved') toast.success('Evaluation criteria saved')
setEditing(false) setPendingCriteria(null)
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
// Sync from server // Convert server criteriaJson to Criterion[] format
if (form && !editing) { const serverCriteria: Criterion[] = useMemo(() => {
const serverCriteria = form.criteriaJson ?? [] if (!form?.criteriaJson) return []
if (JSON.stringify(serverCriteria) !== JSON.stringify(criteria)) { return (form.criteriaJson as Criterion[]).map((c) => {
setCriteria(serverCriteria) // Handle legacy numeric-only format: convert "scale" string like "1-10" back to minScore/maxScore
} const type = c.type || 'numeric'
} if (type === 'numeric' && typeof c.scale === 'string') {
const parts = (c.scale as string).split('-').map(Number)
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion
}
}
return { ...c, type } as Criterion
})
}, [form?.criteriaJson])
const handleAdd = () => { const handleChange = useCallback((criteria: Criterion[]) => {
setCriteria([...criteria, { setPendingCriteria(criteria)
id: `c-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, }, [])
label: '',
description: '',
weight: 1,
minScore: 0,
maxScore: 10,
}])
setEditing(true)
}
const handleRemove = (id: string) => {
setCriteria(criteria.filter((c) => c.id !== id))
}
const handleChange = (id: string, field: string, value: string | number) => {
setCriteria(criteria.map((c) =>
c.id === id ? { ...c, [field]: value } : c,
))
setEditing(true)
}
const handleSave = () => { const handleSave = () => {
const criteria = pendingCriteria ?? serverCriteria
const validCriteria = criteria.filter((c) => c.label.trim()) const validCriteria = criteria.filter((c) => c.label.trim())
if (validCriteria.length === 0) { if (validCriteria.length === 0) {
toast.error('Add at least one criterion') toast.error('Add at least one criterion')
return return
} }
upsertMutation.mutate({ roundId, criteria: validCriteria }) // Map to upsertForm format
upsertMutation.mutate({
roundId,
criteria: validCriteria.map((c) => ({
id: c.id,
label: c.label,
description: c.description,
type: c.type || 'numeric',
weight: c.weight,
scale: typeof c.scale === 'number' ? c.scale : undefined,
minScore: (c as any).minScore,
maxScore: (c as any).maxScore,
required: c.required,
maxLength: c.maxLength,
placeholder: c.placeholder,
trueLabel: c.trueLabel,
falseLabel: c.falseLabel,
condition: c.condition,
sectionId: c.sectionId,
})),
})
} }
return ( return (
@@ -3185,30 +3194,22 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
<div> <div>
<CardTitle className="text-base">Evaluation Criteria</CardTitle> <CardTitle className="text-base">Evaluation Criteria</CardTitle>
<CardDescription> <CardDescription>
{form ? `Version ${form.version} \u2014 ${form.criteriaJson.length} criteria` : 'No criteria defined yet'} {form
? `Version ${form.version} \u2014 ${(form.criteriaJson as Criterion[]).filter((c) => (c.type || 'numeric') !== 'section_header').length} criteria`
: 'No criteria defined yet. Add numeric scores, yes/no questions, and text fields.'}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-2"> {pendingCriteria && (
{editing && ( <div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => { <Button size="sm" variant="outline" onClick={() => setPendingCriteria(null)}>
setEditing(false)
if (form) setCriteria(form.criteriaJson)
}}>
Cancel Cancel
</Button> </Button>
)}
{editing ? (
<Button size="sm" onClick={handleSave} disabled={upsertMutation.isPending}> <Button size="sm" onClick={handleSave} disabled={upsertMutation.isPending}>
{upsertMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />} {upsertMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Save Criteria Save Criteria
</Button> </Button>
) : ( </div>
<Button size="sm" variant="outline" onClick={handleAdd}> )}
<Plus className="h-4 w-4 mr-1.5" />
Add Criterion
</Button>
)}
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -3216,83 +3217,11 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
<div className="space-y-3"> <div className="space-y-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)} {[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
</div> </div>
) : criteria.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
<FileText className="h-8 w-8 mx-auto mb-2 opacity-40" />
<p className="text-sm">No evaluation criteria defined</p>
<p className="text-xs mt-1">Add criteria that jurors will use to score projects</p>
</div>
) : ( ) : (
<div className="space-y-3"> <EvaluationFormBuilder
{criteria.map((c, idx) => ( initialCriteria={serverCriteria}
<div key={c.id} className="flex gap-3 items-start p-3 border rounded-lg bg-muted/20"> onChange={handleChange}
<span className="text-xs text-muted-foreground font-mono mt-2.5 shrink-0 w-5 text-center"> />
{idx + 1}
</span>
<div className="flex-1 space-y-2">
<Input
placeholder="Criterion label (e.g., Innovation)"
value={c.label}
onChange={(e) => handleChange(c.id, 'label', e.target.value)}
className="h-8 text-sm"
/>
<Input
placeholder="Description (optional)"
value={c.description || ''}
onChange={(e) => handleChange(c.id, 'description', e.target.value)}
className="h-7 text-xs"
/>
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground">Weight</Label>
<Input
type="number"
min={0}
max={100}
value={c.weight ?? 1}
onChange={(e) => handleChange(c.id, 'weight', Number(e.target.value))}
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground">Min Score</Label>
<Input
type="number"
min={0}
value={c.minScore ?? 0}
onChange={(e) => handleChange(c.id, 'minScore', Number(e.target.value))}
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground">Max Score</Label>
<Input
type="number"
min={1}
value={c.maxScore ?? 10}
onChange={(e) => handleChange(c.id, 'maxScore', Number(e.target.value))}
className="h-7 text-xs"
/>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 mt-1 shrink-0"
onClick={() => { handleRemove(c.id); setEditing(true) }}
>
<X className="h-3.5 w-3.5 text-muted-foreground hover:text-red-500" />
</Button>
</div>
))}
{!editing && (
<Button variant="outline" size="sm" className="w-full" onClick={handleAdd}>
<Plus className="h-4 w-4 mr-1.5" />
Add Criterion
</Button>
)}
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { use, useState, useEffect } from 'react' import { use, useState, useEffect, useRef, useCallback } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
@@ -12,17 +12,9 @@ import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock } from 'lucide-react' import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2 } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { EvaluationConfig } from '@/types/competition-configs' import type { EvaluationConfig } from '@/types/competition-configs'
@@ -36,15 +28,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const { roundId, projectId } = params const { roundId, projectId } = params
const utils = trpc.useUtils() const utils = trpc.useUtils()
const [showCOIDialog, setShowCOIDialog] = useState(true) // Evaluation form state — stores all criterion values (numeric, boolean, text)
const [coiAccepted, setCoiAccepted] = useState(false) const [criteriaValues, setCriteriaValues] = useState<Record<string, number | boolean | string>>({})
// Evaluation form state
const [criteriaScores, setCriteriaScores] = useState<Record<string, number>>({})
const [globalScore, setGlobalScore] = useState('') const [globalScore, setGlobalScore] = useState('')
const [binaryDecision, setBinaryDecision] = useState<'accept' | 'reject' | ''>('') const [binaryDecision, setBinaryDecision] = useState<'accept' | 'reject' | ''>('')
const [feedbackText, setFeedbackText] = useState('') const [feedbackText, setFeedbackText] = useState('')
// Track dirty state for autosave
const isDirtyRef = useRef(false)
const evaluationIdRef = useRef<string | null>(null)
const isSubmittedRef = useRef(false)
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
// Fetch project // Fetch project
const { data: project } = trpc.project.get.useQuery( const { data: project } = trpc.project.get.useQuery(
{ id: projectId }, { id: projectId },
@@ -71,7 +67,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
{ enabled: !!myAssignment?.id } { enabled: !!myAssignment?.id }
) )
// Fetch the active evaluation form for this round (independent of evaluation existence) // Fetch the active evaluation form for this round
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery( const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId }, { roundId },
{ enabled: !!roundId } { enabled: !!roundId }
@@ -80,17 +76,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
// Start evaluation mutation (creates draft) // Start evaluation mutation (creates draft)
const startMutation = trpc.evaluation.start.useMutation() const startMutation = trpc.evaluation.start.useMutation()
// Autosave mutation // Autosave mutation (silent)
const autosaveMutation = trpc.evaluation.autosave.useMutation({ const autosaveMutation = trpc.evaluation.autosave.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success('Draft saved', { duration: 1500 }) isDirtyRef.current = false
setLastSavedAt(new Date())
}, },
onError: (err) => toast.error(err.message),
}) })
// Submit mutation // Submit mutation
const submitMutation = trpc.evaluation.submit.useMutation({ const submitMutation = trpc.evaluation.submit.useMutation({
onSuccess: () => { onSuccess: () => {
isSubmittedRef.current = true
isDirtyRef.current = false
utils.roundAssignment.getMyAssignments.invalidate() utils.roundAssignment.getMyAssignments.invalidate()
utils.evaluation.get.invalidate() utils.evaluation.get.invalidate()
toast.success('Evaluation submitted successfully') toast.success('Evaluation submitted successfully')
@@ -99,15 +97,24 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
// Track evaluation ID
useEffect(() => {
if (existingEvaluation?.id) {
evaluationIdRef.current = existingEvaluation.id
}
}, [existingEvaluation?.id])
// Load existing evaluation data // Load existing evaluation data
useEffect(() => { useEffect(() => {
if (existingEvaluation) { if (existingEvaluation) {
if (existingEvaluation.criterionScoresJson) { if (existingEvaluation.criterionScoresJson) {
const scores: Record<string, number> = {} const values: Record<string, number | boolean | string> = {}
Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => { Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => {
scores[key] = typeof value === 'number' ? value : 0 if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') {
values[key] = value
}
}) })
setCriteriaScores(scores) setCriteriaValues(values)
} }
if (existingEvaluation.globalScore) { if (existingEvaluation.globalScore) {
setGlobalScore(existingEvaluation.globalScore.toString()) setGlobalScore(existingEvaluation.globalScore.toString())
@@ -118,6 +125,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
if (existingEvaluation.feedbackText) { if (existingEvaluation.feedbackText) {
setFeedbackText(existingEvaluation.feedbackText) setFeedbackText(existingEvaluation.feedbackText)
} }
isDirtyRef.current = false
} }
}, [existingEvaluation]) }, [existingEvaluation])
@@ -127,12 +135,12 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const requireFeedback = evalConfig?.requireFeedback ?? true const requireFeedback = evalConfig?.requireFeedback ?? true
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10 const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
// Get criteria from the active evaluation form (independent of evaluation record) // Parse criteria from the active form
const criteria = (activeForm?.criteriaJson ?? []).map((c) => { const criteria = (activeForm?.criteriaJson ?? []).map((c) => {
// Parse scale string like "1-10" into minScore/maxScore const type = (c as any).type || 'numeric'
let minScore = 1 let minScore = 1
let maxScore = 10 let maxScore = 10
if (c.scale) { if (type === 'numeric' && c.scale) {
const parts = c.scale.split('-').map(Number) const parts = c.scale.split('-').map(Number)
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) { if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
minScore = parts[0] minScore = parts[0]
@@ -143,33 +151,135 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
id: c.id, id: c.id,
label: c.label, label: c.label,
description: c.description, description: c.description,
type: type as 'numeric' | 'text' | 'boolean' | 'section_header',
weight: c.weight, weight: c.weight,
minScore, minScore,
maxScore, maxScore,
required: (c as any).required ?? true,
trueLabel: (c as any).trueLabel || 'Yes',
falseLabel: (c as any).falseLabel || 'No',
maxLength: (c as any).maxLength || 1000,
placeholder: (c as any).placeholder || '',
} }
}) })
// Build current form data for autosave
const buildSavePayload = useCallback(() => {
return {
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : undefined,
globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null,
binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null,
feedbackText: feedbackText || null,
}
}, [scoringMode, criteriaValues, globalScore, binaryDecision, feedbackText])
// Perform autosave
const performAutosave = useCallback(async () => {
if (!isDirtyRef.current || isSubmittedRef.current) return
if (existingEvaluation?.status === 'SUBMITTED') return
let evalId = evaluationIdRef.current
if (!evalId && myAssignment) {
try {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evalId = newEval.id
evaluationIdRef.current = evalId
} catch {
return
}
}
if (!evalId) return
autosaveMutation.mutate({ id: evalId, ...buildSavePayload() })
}, [myAssignment, existingEvaluation?.status, startMutation, autosaveMutation, buildSavePayload])
// Debounced autosave: save 3 seconds after last change
useEffect(() => {
if (!isDirtyRef.current) return
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current)
}
autosaveTimerRef.current = setTimeout(() => {
performAutosave()
}, 3000)
return () => {
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current)
}
}
}, [criteriaValues, globalScore, binaryDecision, feedbackText, performAutosave])
// Save on page leave (beforeunload)
useEffect(() => {
const handleBeforeUnload = () => {
if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) {
const payload = JSON.stringify({
id: evaluationIdRef.current,
...buildSavePayload(),
})
navigator.sendBeacon?.('/api/trpc/evaluation.autosave', payload)
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [buildSavePayload])
// Save on component unmount (navigating away within the app)
useEffect(() => {
return () => {
if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) {
performAutosave()
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Mark dirty when form values change
const handleCriterionChange = (key: string, value: number | boolean | string) => {
setCriteriaValues((prev) => ({ ...prev, [key]: value }))
isDirtyRef.current = true
}
const handleGlobalScoreChange = (value: string) => {
setGlobalScore(value)
isDirtyRef.current = true
}
const handleBinaryChange = (value: 'accept' | 'reject') => {
setBinaryDecision(value)
isDirtyRef.current = true
}
const handleFeedbackChange = (value: string) => {
setFeedbackText(value)
isDirtyRef.current = true
}
const handleSaveDraft = async () => { const handleSaveDraft = async () => {
if (!myAssignment) { if (!myAssignment) {
toast.error('Assignment not found') toast.error('Assignment not found')
return return
} }
// Create evaluation if it doesn't exist let evaluationId = evaluationIdRef.current
let evaluationId = existingEvaluation?.id
if (!evaluationId) { if (!evaluationId) {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id }) const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evaluationId = newEval.id evaluationId = newEval.id
evaluationIdRef.current = evaluationId
} }
// Autosave current state autosaveMutation.mutate(
autosaveMutation.mutate({ { id: evaluationId, ...buildSavePayload() },
id: evaluationId, { onSuccess: () => {
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : undefined, isDirtyRef.current = false
globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null, setLastSavedAt(new Date())
binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null, toast.success('Draft saved', { duration: 1500 })
feedbackText: feedbackText || null, }}
}) )
} }
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -178,17 +288,23 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
return return
} }
// Validation based on scoring mode // Validation for criteria mode
if (scoringMode === 'criteria') { if (scoringMode === 'criteria') {
if (!criteria || criteria.length === 0) { const requiredCriteria = criteria.filter((c) =>
toast.error('No criteria found for this evaluation') c.type !== 'section_header' && c.required
return )
} for (const c of requiredCriteria) {
const requiredCriteria = evalConfig?.requireAllCriteriaScored !== false const val = criteriaValues[c.id]
if (requiredCriteria) { if (c.type === 'numeric' && (val === undefined || val === null)) {
const allScored = criteria.every((c) => criteriaScores[c.id] !== undefined) toast.error(`Please score "${c.label}"`)
if (!allScored) { return
toast.error('Please score all criteria') }
if (c.type === 'boolean' && val === undefined) {
toast.error(`Please answer "${c.label}"`)
return
}
if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) {
toast.error(`Please fill in "${c.label}"`)
return return
} }
} }
@@ -216,74 +332,43 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
} }
} }
// Create evaluation if needed let evaluationId = evaluationIdRef.current
let evaluationId = existingEvaluation?.id
if (!evaluationId) { if (!evaluationId) {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id }) const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evaluationId = newEval.id evaluationId = newEval.id
evaluationIdRef.current = evaluationId
}
// Compute a weighted global score from numeric criteria for the global score field
const numericCriteria = criteria.filter((c) => c.type === 'numeric')
let computedGlobalScore = 5
if (scoringMode === 'criteria' && numericCriteria.length > 0) {
let totalWeight = 0
let weightedSum = 0
for (const c of numericCriteria) {
const val = criteriaValues[c.id]
if (typeof val === 'number') {
const w = c.weight ?? 1
// Normalize to 1-10 scale
const normalized = ((val - c.minScore) / (c.maxScore - c.minScore)) * 9 + 1
weightedSum += normalized * w
totalWeight += w
}
}
if (totalWeight > 0) {
computedGlobalScore = Math.round(weightedSum / totalWeight)
}
} }
// Submit
submitMutation.mutate({ submitMutation.mutate({
id: evaluationId, id: evaluationId,
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : {}, criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : {},
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : 5, globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : computedGlobalScore,
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true, binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
feedbackText: feedbackText || 'No feedback provided', feedbackText: feedbackText || 'No feedback provided',
}) })
} }
// COI Dialog
if (!coiAccepted && showCOIDialog && evalConfig?.coiRequired !== false) {
return (
<AlertDialog open={showCOIDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Conflict of Interest Declaration</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3 pt-2">
<p>
Before evaluating this project, you must confirm that you have no conflict of
interest.
</p>
<p>
A conflict of interest exists if you have a personal, professional, or financial
relationship with the project team that could influence your judgment.
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex items-start gap-3 py-4">
<Checkbox
id="coi"
checked={coiAccepted}
onCheckedChange={(checked) => setCoiAccepted(checked as boolean)}
/>
<Label htmlFor="coi" className="text-sm leading-relaxed cursor-pointer">
I confirm that I have no conflict of interest with this project and can provide an
unbiased evaluation.
</Label>
</div>
<AlertDialogFooter>
<Button
variant="outline"
onClick={() => router.push(`/jury/competitions/${roundId}` as Route)}
>
Cancel
</Button>
<Button
onClick={() => setShowCOIDialog(false)}
disabled={!coiAccepted}
className="bg-brand-blue hover:bg-brand-blue-light"
>
Continue to Evaluation
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
if (!round || !project) { if (!round || !project) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -299,7 +384,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
) )
} }
// Check if round is active — round status is the primary gate for evaluations // Check if round is active
const isRoundActive = round.status === 'ROUND_ACTIVE' const isRoundActive = round.status === 'ROUND_ACTIVE'
if (!isRoundActive) { if (!isRoundActive) {
@@ -352,6 +437,9 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</div> </div>
</div> </div>
{/* Project Documents */}
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
<Card className="border-l-4 border-l-amber-500"> <Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-3 p-4"> <CardContent className="flex items-start gap-3 p-4">
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" /> <AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
@@ -359,7 +447,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<p className="font-medium text-sm">Important Reminder</p> <p className="font-medium text-sm">Important Reminder</p>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Your evaluation will be used to assess this project. Please provide thoughtful and Your evaluation will be used to assess this project. Please provide thoughtful and
constructive feedback to help the team improve. constructive feedback. Your progress is automatically saved as a draft.
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -367,21 +455,116 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Evaluation Form</CardTitle> <div className="flex items-start justify-between">
<CardDescription> <div>
Provide your assessment using the {scoringMode} scoring method <CardTitle>Evaluation Form</CardTitle>
</CardDescription> <CardDescription>
{scoringMode === 'criteria'
? 'Complete all required fields below'
: `Provide your assessment using the ${scoringMode} scoring method`}
</CardDescription>
</div>
{lastSavedAt && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
Saved {lastSavedAt.toLocaleTimeString()}
</span>
)}
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Criteria-based scoring */} {/* Criteria-based scoring with mixed types */}
{scoringMode === 'criteria' && criteria && criteria.length > 0 && ( {scoringMode === 'criteria' && criteria && criteria.length > 0 && (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-semibold">Criteria Scores</h3>
{criteria.map((criterion) => { {criteria.map((criterion) => {
if (criterion.type === 'section_header') {
return (
<div key={criterion.id} className="border-b pb-2 pt-4 first:pt-0">
<h3 className="font-semibold text-lg">{criterion.label}</h3>
{criterion.description && (
<p className="text-sm text-muted-foreground mt-1">{criterion.description}</p>
)}
</div>
)
}
if (criterion.type === 'boolean') {
const currentValue = criteriaValues[criterion.id]
return (
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
<div className="space-y-1">
<Label className="text-base font-medium">
{criterion.label}
{criterion.required && <span className="text-destructive ml-1">*</span>}
</Label>
{criterion.description && (
<p className="text-sm text-muted-foreground">{criterion.description}</p>
)}
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => handleCriterionChange(criterion.id, true)}
className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === true
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
)}
>
<ThumbsUp className="mr-2 h-4 w-4" />
{criterion.trueLabel}
</button>
<button
type="button"
onClick={() => handleCriterionChange(criterion.id, false)}
className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === false
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
: 'border-border hover:border-red-300 hover:bg-red-50/50'
)}
>
<ThumbsDown className="mr-2 h-4 w-4" />
{criterion.falseLabel}
</button>
</div>
</div>
)
}
if (criterion.type === 'text') {
const currentValue = (criteriaValues[criterion.id] as string) || ''
return (
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
<div className="space-y-1">
<Label className="text-base font-medium">
{criterion.label}
{criterion.required && <span className="text-destructive ml-1">*</span>}
</Label>
{criterion.description && (
<p className="text-sm text-muted-foreground">{criterion.description}</p>
)}
</div>
<Textarea
value={currentValue}
onChange={(e) => handleCriterionChange(criterion.id, e.target.value)}
placeholder={criterion.placeholder || 'Enter your response...'}
rows={4}
maxLength={criterion.maxLength}
/>
<p className="text-xs text-muted-foreground text-right">
{currentValue.length}/{criterion.maxLength}
</p>
</div>
)
}
// Default: numeric criterion
const min = criterion.minScore ?? 1 const min = criterion.minScore ?? 1
const max = criterion.maxScore ?? 10 const max = criterion.maxScore ?? 10
const currentValue = criteriaScores[criterion.id] const currentValue = criteriaValues[criterion.id]
const displayValue = currentValue !== undefined ? currentValue : undefined const displayValue = typeof currentValue === 'number' ? currentValue : undefined
const sliderValue = typeof currentValue === 'number' ? currentValue : Math.ceil((min + max) / 2) const sliderValue = typeof currentValue === 'number' ? currentValue : Math.ceil((min + max) / 2)
return ( return (
@@ -390,16 +573,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-base font-medium"> <Label className="text-base font-medium">
{criterion.label} {criterion.label}
{evalConfig?.requireAllCriteriaScored !== false && ( {criterion.required && <span className="text-destructive ml-1">*</span>}
<span className="text-destructive ml-1">*</span>
)}
</Label> </Label>
{criterion.description && ( {criterion.description && (
<p className="text-sm text-muted-foreground">{criterion.description}</p> <p className="text-sm text-muted-foreground">{criterion.description}</p>
)} )}
</div> </div>
<span className="shrink-0 rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums"> <span className="shrink-0 rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
{displayValue !== undefined ? displayValue : ''}/{max} {displayValue !== undefined ? displayValue : '\u2014'}/{max}
</span> </span>
</div> </div>
@@ -410,9 +591,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
max={max} max={max}
step={1} step={1}
value={[sliderValue]} value={[sliderValue]}
onValueChange={(v) => onValueChange={(v) => handleCriterionChange(criterion.id, v[0])}
setCriteriaScores({ ...criteriaScores, [criterion.id]: v[0] })
}
className="flex-1" className="flex-1"
/> />
<span className="text-xs text-muted-foreground w-4">{max}</span> <span className="text-xs text-muted-foreground w-4">{max}</span>
@@ -423,9 +602,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<button <button
key={num} key={num}
type="button" type="button"
onClick={() => onClick={() => handleCriterionChange(criterion.id, num)}
setCriteriaScores({ ...criteriaScores, [criterion.id]: num })
}
className={cn( className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors', 'w-9 h-9 rounded-md text-sm font-medium transition-colors',
displayValue !== undefined && displayValue === num displayValue !== undefined && displayValue === num
@@ -453,7 +630,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
Overall Score <span className="text-destructive">*</span> Overall Score <span className="text-destructive">*</span>
</Label> </Label>
<span className="rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums"> <span className="rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
{globalScore || ''}/10 {globalScore || '\u2014'}/10
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -463,7 +640,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
max={10} max={10}
step={1} step={1}
value={[globalScore ? parseInt(globalScore, 10) : 5]} value={[globalScore ? parseInt(globalScore, 10) : 5]}
onValueChange={(v) => setGlobalScore(v[0].toString())} onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
className="flex-1" className="flex-1"
/> />
<span className="text-xs text-muted-foreground">10</span> <span className="text-xs text-muted-foreground">10</span>
@@ -475,7 +652,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<button <button
key={num} key={num}
type="button" type="button"
onClick={() => setGlobalScore(num.toString())} onClick={() => handleGlobalScoreChange(num.toString())}
className={cn( className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors', 'w-9 h-9 rounded-md text-sm font-medium transition-colors',
current === num current === num
@@ -490,9 +667,6 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
) )
})} })}
</div> </div>
<p className="text-xs text-muted-foreground">
Provide a score from 1 to 10 based on your overall assessment
</p>
</div> </div>
)} )}
@@ -502,7 +676,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<Label> <Label>
Decision <span className="text-destructive">*</span> Decision <span className="text-destructive">*</span>
</Label> </Label>
<RadioGroup value={binaryDecision} onValueChange={(v) => setBinaryDecision(v as 'accept' | 'reject')}> <RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')}>
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50"> <div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
<RadioGroupItem value="accept" id="accept" /> <RadioGroupItem value="accept" id="accept" />
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1"> <Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
@@ -524,13 +698,13 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
{/* Feedback */} {/* Feedback */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="feedbackText"> <Label htmlFor="feedbackText">
Feedback General Comment / Feedback
{requireFeedback && <span className="text-destructive ml-1">*</span>} {requireFeedback && <span className="text-destructive ml-1">*</span>}
</Label> </Label>
<Textarea <Textarea
id="feedbackText" id="feedbackText"
value={feedbackText} value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)} onChange={(e) => handleFeedbackChange(e.target.value)}
placeholder="Provide your feedback on the project..." placeholder="Provide your feedback on the project..."
rows={8} rows={8}
/> />

View File

@@ -9,8 +9,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer' import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
import { ArrowLeft, FileText, Users, MapPin, Target } from 'lucide-react' import { ArrowLeft, FileText, Users, MapPin, Target, Tag } from 'lucide-react'
import { toast } from 'sonner'
export default function JuryProjectDetailPage() { export default function JuryProjectDetailPage() {
const params = useParams() const params = useParams()
@@ -105,15 +104,41 @@ export default function JuryProjectDetailPage() {
{project.competitionCategory && ( {project.competitionCategory && (
<Badge variant="outline">{project.competitionCategory}</Badge> <Badge variant="outline">{project.competitionCategory}</Badge>
)} )}
{project.tags && project.tags.length > 0 && (
project.tags.slice(0, 3).map((tag: string) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))
)}
</div> </div>
{/* Project tags */}
{((project.projectTags && project.projectTags.length > 0) ||
(project.tags && project.tags.length > 0)) && (
<div>
<h3 className="font-semibold mb-2 flex items-center gap-2">
<Tag className="h-4 w-4" />
Tags
</h3>
<div className="flex flex-wrap gap-2">
{project.projectTags && project.projectTags.length > 0
? project.projectTags.map((pt: any) => (
<Badge
key={pt.id}
variant="secondary"
style={pt.tag.color ? { backgroundColor: pt.tag.color + '20', borderColor: pt.tag.color, color: pt.tag.color } : undefined}
className="text-xs"
>
{pt.tag.name}
{pt.tag.category && (
<span className="ml-1 opacity-60">({pt.tag.category})</span>
)}
</Badge>
))
: project.tags.map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))
}
</div>
</div>
)}
{/* Description */} {/* Description */}
{project.description && ( {project.description && (
<div> <div>

View File

@@ -42,10 +42,21 @@ interface EvaluationSummaryCardProps {
roundId: string roundId: string
} }
interface BooleanStats {
yesCount: number
noCount: number
total: number
yesPercent: number
trueLabel: string
falseLabel: string
}
interface ScoringPatterns { interface ScoringPatterns {
averageGlobalScore: number | null averageGlobalScore: number | null
consensus: number consensus: number
criterionAverages: Record<string, number> criterionAverages: Record<string, number>
booleanCriteria?: Record<string, BooleanStats>
textResponses?: Record<string, string[]>
evaluatorCount: number evaluatorCount: number
} }
@@ -296,10 +307,10 @@ export function EvaluationSummaryCard({
</div> </div>
)} )}
{/* Criterion Averages */} {/* Criterion Averages (Numeric) */}
{Object.keys(patterns.criterionAverages).length > 0 && ( {Object.keys(patterns.criterionAverages).length > 0 && (
<div> <div>
<p className="text-sm font-medium mb-2">Criterion Averages</p> <p className="text-sm font-medium mb-2">Score Averages</p>
<div className="space-y-2"> <div className="space-y-2">
{Object.entries(patterns.criterionAverages).map(([label, avg]) => ( {Object.entries(patterns.criterionAverages).map(([label, avg]) => (
<div key={label} className="flex items-center gap-3"> <div key={label} className="flex items-center gap-3">
@@ -323,6 +334,69 @@ export function EvaluationSummaryCard({
</div> </div>
)} )}
{/* Boolean Criteria (Yes/No) */}
{patterns.booleanCriteria && Object.keys(patterns.booleanCriteria).length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Yes/No Decisions</p>
<div className="space-y-3">
{Object.entries(patterns.booleanCriteria).map(([label, stats]) => (
<div key={label} className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground truncate">
{label}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{stats.yesCount} {stats.trueLabel} / {stats.noCount} {stats.falseLabel}
</span>
</div>
<div className="flex h-2 rounded-full overflow-hidden bg-muted">
{stats.yesCount > 0 && (
<div
className="h-full bg-emerald-500 transition-all"
style={{ width: `${stats.yesPercent}%` }}
/>
)}
{stats.noCount > 0 && (
<div
className="h-full bg-red-400 transition-all"
style={{ width: `${100 - stats.yesPercent}%` }}
/>
)}
</div>
<div className="flex justify-between text-xs">
<span className="text-emerald-600">{stats.yesPercent}% {stats.trueLabel}</span>
<span className="text-red-500">{100 - stats.yesPercent}% {stats.falseLabel}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Text Responses */}
{patterns.textResponses && Object.keys(patterns.textResponses).length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Text Responses</p>
<div className="space-y-3">
{Object.entries(patterns.textResponses).map(([label, responses]) => (
<div key={label} className="space-y-1.5">
<p className="text-sm text-muted-foreground">{label}</p>
<div className="space-y-1.5">
{responses.map((text, i) => (
<div
key={i}
className="text-sm p-2 rounded border bg-muted/50 whitespace-pre-wrap"
>
{text}
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* Recommendation */} {/* Recommendation */}
{summaryData.recommendation && ( {summaryData.recommendation && (
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-200"> <div className="p-3 rounded-lg bg-blue-500/10 border border-blue-200">

View File

@@ -0,0 +1,114 @@
'use client'
import Link from 'next/link'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ClipboardCheck, ThumbsUp, ThumbsDown, ExternalLink } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
type RecentEvaluation = {
id: string
globalScore: number | null
binaryDecision: boolean | null
submittedAt: Date | string | null
feedbackText: string | null
assignment: {
project: { id: string; title: string }
round: { id: string; name: string }
user: { id: string; name: string | null; email: string }
}
}
export function RecentEvaluations({ evaluations }: { evaluations: RecentEvaluation[] }) {
if (!evaluations || evaluations.length === 0) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardCheck className="h-4 w-4" />
Recent Evaluations
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground text-center py-4">
No evaluations submitted yet
</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardCheck className="h-4 w-4" />
Recent Evaluations
</CardTitle>
<CardDescription>Latest jury reviews as they come in</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{evaluations.map((ev) => (
<Link
key={ev.id}
href={`/admin/projects/${ev.assignment.project.id}`}
className="block group"
>
<div className="flex items-start gap-3 p-2.5 rounded-lg border hover:bg-muted/50 transition-colors">
{/* Score indicator */}
<div className="flex flex-col items-center gap-0.5 shrink-0 pt-0.5">
{ev.globalScore !== null ? (
<span className="text-lg font-bold tabular-nums leading-none">
{ev.globalScore}
</span>
) : (
<span className="text-lg font-bold text-muted-foreground leading-none">-</span>
)}
<span className="text-[10px] text-muted-foreground">/10</span>
</div>
{/* Details */}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate flex-1">
{ev.assignment.project.title}
</p>
<ExternalLink className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{ev.assignment.user.name || ev.assignment.user.email}</span>
<span className="shrink-0">
{ev.submittedAt
? formatDistanceToNow(new Date(ev.submittedAt), { addSuffix: true })
: ''}
</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] h-5">
{ev.assignment.round.name}
</Badge>
{ev.binaryDecision !== null && (
ev.binaryDecision ? (
<span className="flex items-center gap-0.5 text-xs text-emerald-600">
<ThumbsUp className="h-3 w-3" /> Yes
</span>
) : (
<span className="flex items-center gap-0.5 text-xs text-red-500">
<ThumbsDown className="h-3 w-3" /> No
</span>
)
)}
</div>
{ev.feedbackText && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
{ev.feedbackText}
</p>
)}
</div>
</div>
</Link>
))}
</CardContent>
</Card>
)
}

View File

@@ -1,12 +1,12 @@
'use client' 'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { FileText, Download, ExternalLink } from 'lucide-react' import { FileText, Download, ExternalLink, Loader2 } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
interface MultiWindowDocViewerProps { interface MultiWindowDocViewerProps {
@@ -32,10 +32,110 @@ function getFileIcon(mimeType: string) {
return '📎' return '📎'
} }
function canPreviewInBrowser(mimeType: string): boolean {
return (
mimeType === 'application/pdf' ||
mimeType.startsWith('image/') ||
mimeType.startsWith('video/') ||
mimeType.startsWith('text/')
)
}
function FileCard({ file }: { file: { id: string; fileName: string; mimeType: string; size: number; bucket: string; objectKey: string } }) {
const [loadingAction, setLoadingAction] = useState<'download' | 'preview' | null>(null)
const downloadUrlQuery = trpc.file.getDownloadUrl.useQuery(
{ bucket: file.bucket, objectKey: file.objectKey },
{ enabled: false } // manual trigger
)
const handleAction = async (action: 'download' | 'preview') => {
setLoadingAction(action)
try {
const result = await downloadUrlQuery.refetch()
if (result.data?.url) {
if (action === 'preview' && canPreviewInBrowser(file.mimeType)) {
window.open(result.data.url, '_blank')
} else {
// Download: create temp link and click
const a = document.createElement('a')
a.href = result.data.url
a.download = file.fileName
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
}
} catch {
toast.error('Failed to get file URL')
} finally {
setLoadingAction(null)
}
}
return (
<Card className="overflow-hidden">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-2xl">{getFileIcon(file.mimeType || '')}</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate" title={file.fileName}>
{file.fileName}
</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{file.mimeType?.split('/')[1]?.toUpperCase() || 'FILE'}
</Badge>
{file.size > 0 && (
<span className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</span>
)}
</div>
<div className="flex gap-2 mt-3">
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => handleAction('download')}
disabled={loadingAction !== null}
>
{loadingAction === 'download' ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Download className="mr-1 h-3 w-3" />
)}
Download
</Button>
{canPreviewInBrowser(file.mimeType) && (
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={() => handleAction('preview')}
disabled={loadingAction !== null}
>
{loadingAction === 'preview' ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<ExternalLink className="mr-1 h-3 w-3" />
)}
Preview
</Button>
)}
</div>
</div>
</div>
</CardContent>
</Card>
)
}
export function MultiWindowDocViewer({ roundId, projectId }: MultiWindowDocViewerProps) { export function MultiWindowDocViewer({ roundId, projectId }: MultiWindowDocViewerProps) {
const { data: windows, isLoading } = trpc.round.getVisibleWindows.useQuery( const { data: files, isLoading } = trpc.file.listByProject.useQuery(
{ roundId }, { projectId },
{ enabled: !!roundId } { enabled: !!projectId }
) )
if (isLoading) { if (isLoading) {
@@ -51,94 +151,67 @@ export function MultiWindowDocViewer({ roundId, projectId }: MultiWindowDocViewe
) )
} }
if (!windows || windows.length === 0) { if (!files || files.length === 0) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Documents</CardTitle> <CardTitle>Documents</CardTitle>
<CardDescription>Submission windows and uploaded files</CardDescription> <CardDescription>Project files and submissions</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="text-center py-8"> <CardContent className="text-center py-8">
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" /> <FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No submission windows available</p> <p className="text-sm text-muted-foreground">No files uploaded</p>
</CardContent> </CardContent>
</Card> </Card>
) )
} }
// Group files by round name or "General"
const grouped: Record<string, typeof files> = {}
for (const file of files) {
const groupName = file.requirement?.round?.name ?? 'General'
if (!grouped[groupName]) grouped[groupName] = []
grouped[groupName].push(file)
}
const groupNames = Object.keys(grouped)
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Documents</CardTitle> <CardTitle>Documents</CardTitle>
<CardDescription>Files submitted across all windows</CardDescription> <CardDescription>
{files.length} file{files.length !== 1 ? 's' : ''} submitted
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Tabs defaultValue={windows[0]?.id || ''} className="w-full"> {groupNames.length === 1 ? (
<TabsList className="w-full flex-wrap justify-start h-auto gap-1 bg-transparent p-0 mb-4"> // Single group — no need for headers
{windows.map((window: any) => ( <div className="grid gap-3 sm:grid-cols-2">
<TabsTrigger {grouped[groupNames[0]].map((file) => (
key={window.id} <FileCard key={file.id} file={file} />
value={window.id}
className="data-[state=active]:bg-brand-blue data-[state=active]:text-white px-4 py-2 rounded-md text-sm"
>
{window.name}
{window.files && window.files.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{window.files.length}
</Badge>
)}
</TabsTrigger>
))} ))}
</TabsList> </div>
) : (
{windows.map((window: any) => ( // Multiple groups — show headers
<TabsContent key={window.id} value={window.id} className="mt-0"> <div className="space-y-6">
{!window.files || window.files.length === 0 ? ( {groupNames.map((groupName) => (
<div className="text-center py-8 border border-dashed rounded-lg"> <div key={groupName}>
<FileText className="h-10 w-10 text-muted-foreground/50 mx-auto mb-2" /> <h4 className="font-medium text-sm text-muted-foreground mb-3">
<p className="text-sm text-muted-foreground">No files uploaded</p> {groupName}
</div> <Badge variant="secondary" className="ml-2 text-xs">
) : ( {grouped[groupName].length}
</Badge>
</h4>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{window.files.map((file: any) => ( {grouped[groupName].map((file) => (
<Card key={file.id} className="overflow-hidden"> <FileCard key={file.id} file={file} />
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-2xl">{getFileIcon(file.mimeType || '')}</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate" title={file.filename}>
{file.filename}
</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{file.mimeType?.split('/')[1]?.toUpperCase() || 'FILE'}
</Badge>
{file.size && (
<span className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</span>
)}
</div>
<div className="flex gap-2 mt-3">
<Button size="sm" variant="outline" className="h-7 text-xs">
<Download className="mr-1 h-3 w-3" />
Download
</Button>
<Button size="sm" variant="ghost" className="h-7 text-xs">
<ExternalLink className="mr-1 h-3 w-3" />
Preview
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
))} ))}
</div> </div>
)} </div>
</TabsContent> ))}
))} </div>
</Tabs> )}
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -465,4 +465,37 @@ export const dashboardRouter = router({
recentActivity, recentActivity,
} }
}), }),
getRecentEvaluations: adminProcedure
.input(z.object({ editionId: z.string(), limit: z.number().int().min(1).max(50).optional() }))
.query(async ({ ctx, input }) => {
const take = input.limit ?? 10
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: {
round: { competition: { programId: input.editionId } },
},
},
orderBy: { submittedAt: 'desc' },
take,
select: {
id: true,
globalScore: true,
binaryDecision: true,
submittedAt: true,
feedbackText: true,
assignment: {
select: {
project: { select: { id: true, title: true } },
round: { select: { id: true, name: true } },
user: { select: { id: true, name: true, email: true } },
},
},
},
})
return evaluations
}),
}) })

View File

@@ -1067,9 +1067,27 @@ export const evaluationRouter = router({
id: z.string(), id: z.string(),
label: z.string().min(1).max(255), label: z.string().min(1).max(255),
description: z.string().max(2000).optional(), description: z.string().max(2000).optional(),
type: z.enum(['numeric', 'text', 'boolean', 'section_header']).optional(),
// Numeric fields
weight: z.number().min(0).max(100).optional(), weight: z.number().min(0).max(100).optional(),
minScore: z.number().int().min(0).optional(), minScore: z.number().int().min(0).optional(),
maxScore: z.number().int().min(1).optional(), maxScore: z.number().int().min(1).optional(),
scale: z.number().int().min(1).max(10).optional(),
required: z.boolean().optional(),
// Text fields
maxLength: z.number().int().min(1).max(10000).optional(),
placeholder: z.string().max(500).optional(),
// Boolean fields
trueLabel: z.string().max(100).optional(),
falseLabel: z.string().max(100).optional(),
// Conditional visibility
condition: z.object({
criterionId: z.string(),
operator: z.enum(['equals', 'greaterThan', 'lessThan']),
value: z.union([z.number(), z.string(), z.boolean()]),
}).optional(),
// Section grouping
sectionId: z.string().optional(),
}) })
).min(1), ).min(1),
}) })
@@ -1088,18 +1106,46 @@ export const evaluationRouter = router({
}) })
const nextVersion = (latestForm?.version ?? 0) + 1 const nextVersion = (latestForm?.version ?? 0) + 1
// Build criteriaJson with defaults // Build criteriaJson preserving all fields
const criteriaJson = criteria.map((c) => ({ const criteriaJson = criteria.map((c) => {
id: c.id, const type = c.type || 'numeric'
label: c.label, const base = {
description: c.description || '', id: c.id,
weight: c.weight ?? 1, label: c.label,
scale: `${c.minScore ?? 1}-${c.maxScore ?? 10}`, description: c.description || '',
required: true, type,
})) required: c.required ?? (type !== 'section_header'),
}
// Auto-generate scalesJson from criteria min/max ranges if (type === 'numeric') {
const scaleSet = new Set(criteriaJson.map((c) => c.scale)) const scaleVal = c.scale ?? 10
return {
...base,
weight: c.weight ?? 1,
scale: `${c.minScore ?? 1}-${c.maxScore ?? scaleVal}`,
}
}
if (type === 'text') {
return {
...base,
maxLength: c.maxLength ?? 1000,
placeholder: c.placeholder || '',
}
}
if (type === 'boolean') {
return {
...base,
trueLabel: c.trueLabel || 'Yes',
falseLabel: c.falseLabel || 'No',
}
}
// section_header
return base
})
// Auto-generate scalesJson from numeric criteria
const numericCriteria = criteriaJson.filter((c) => c.type === 'numeric')
const scaleSet = new Set(numericCriteria.map((c) => (c as { scale: string }).scale))
const scalesJson: Record<string, { min: number; max: number }> = {} const scalesJson: Record<string, { min: number; max: number }> = {}
for (const scale of scaleSet) { for (const scale of scaleSet) {
const [min, max] = scale.split('-').map(Number) const [min, max] = scale.split('-').map(Number)

View File

@@ -1141,7 +1141,8 @@ export const projectRouter = router({
where: { projectId: input.id }, where: { projectId: input.id },
include: { include: {
user: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true } }, user: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true } },
evaluation: { select: { status: true, submittedAt: true, globalScore: true, binaryDecision: true } }, round: { select: { id: true, name: true } },
evaluation: { select: { id: true, status: true, submittedAt: true, globalScore: true, binaryDecision: true, criterionScoresJson: true, feedbackText: true } },
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}), }),

View File

@@ -21,7 +21,7 @@ import type { PrismaClient, Prisma } from '@prisma/client'
interface EvaluationForSummary { interface EvaluationForSummary {
id: string id: string
criterionScoresJson: Record<string, number> | null criterionScoresJson: Record<string, number | boolean | string> | null
globalScore: number | null globalScore: number | null
binaryDecision: boolean | null binaryDecision: boolean | null
feedbackText: string | null feedbackText: string | null
@@ -35,7 +35,7 @@ interface EvaluationForSummary {
} }
interface AnonymizedEvaluation { interface AnonymizedEvaluation {
criterionScores: Record<string, number> | null criterionScores: Record<string, number | boolean | string> | null
globalScore: number | null globalScore: number | null
binaryDecision: boolean | null binaryDecision: boolean | null
feedbackText: string | null feedbackText: string | null
@@ -44,6 +44,9 @@ interface AnonymizedEvaluation {
interface CriterionDef { interface CriterionDef {
id: string id: string
label: string label: string
type?: 'numeric' | 'text' | 'boolean' | 'section_header'
trueLabel?: string
falseLabel?: string
} }
interface AIResponsePayload { interface AIResponsePayload {
@@ -58,10 +61,21 @@ interface AIResponsePayload {
recommendation: string recommendation: string
} }
interface BooleanStats {
yesCount: number
noCount: number
total: number
yesPercent: number
trueLabel: string
falseLabel: string
}
interface ScoringPatterns { interface ScoringPatterns {
averageGlobalScore: number | null averageGlobalScore: number | null
consensus: number consensus: number
criterionAverages: Record<string, number> criterionAverages: Record<string, number>
booleanCriteria: Record<string, BooleanStats>
textResponses: Record<string, string[]>
evaluatorCount: number evaluatorCount: number
} }
@@ -84,7 +98,7 @@ export function anonymizeEvaluations(
evaluations: EvaluationForSummary[] evaluations: EvaluationForSummary[]
): AnonymizedEvaluation[] { ): AnonymizedEvaluation[] {
return evaluations.map((ev) => ({ return evaluations.map((ev) => ({
criterionScores: ev.criterionScoresJson as Record<string, number> | null, criterionScores: ev.criterionScoresJson as Record<string, number | boolean | string> | null,
globalScore: ev.globalScore, globalScore: ev.globalScore,
binaryDecision: ev.binaryDecision, binaryDecision: ev.binaryDecision,
feedbackText: ev.feedbackText ? sanitizeText(ev.feedbackText) : null, feedbackText: ev.feedbackText ? sanitizeText(ev.feedbackText) : null,
@@ -99,15 +113,33 @@ export function anonymizeEvaluations(
export function buildSummaryPrompt( export function buildSummaryPrompt(
anonymizedEvaluations: AnonymizedEvaluation[], anonymizedEvaluations: AnonymizedEvaluation[],
projectTitle: string, projectTitle: string,
criteriaLabels: string[] criteriaDefinitions: CriterionDef[]
): string { ): string {
const sanitizedTitle = sanitizeText(projectTitle) const sanitizedTitle = sanitizeText(projectTitle)
// Build a descriptive criteria section that explains each criterion type
const criteriaDescription = criteriaDefinitions
.filter((c) => c.type !== 'section_header')
.map((c) => {
const type = c.type || 'numeric'
if (type === 'boolean') {
return `- "${c.label}" (Yes/No decision: ${c.trueLabel || 'Yes'} / ${c.falseLabel || 'No'})`
}
if (type === 'text') {
return `- "${c.label}" (Free-text response)`
}
return `- "${c.label}" (Numeric score)`
})
.join('\n')
return `You are analyzing jury evaluations for a project competition. return `You are analyzing jury evaluations for a project competition.
PROJECT: "${sanitizedTitle}" PROJECT: "${sanitizedTitle}"
EVALUATION CRITERIA: ${criteriaLabels.join(', ')} EVALUATION CRITERIA:
${criteriaDescription}
Note: criterionScores values may be numbers (numeric scores), booleans (true/false for yes/no criteria), or strings (text responses).
EVALUATIONS (${anonymizedEvaluations.length} total): EVALUATIONS (${anonymizedEvaluations.length} total):
${JSON.stringify(anonymizedEvaluations, null, 2)} ${JSON.stringify(anonymizedEvaluations, null, 2)}
@@ -123,17 +155,11 @@ Analyze these evaluations and return a JSON object with this exact structure:
"recommendation": "A brief recommendation based on the evaluation consensus" "recommendation": "A brief recommendation based on the evaluation consensus"
} }
Example output:
{
"overallAssessment": "The project received strong scores (avg 7.8/10) with high consensus among evaluators. Key strengths in innovation were balanced by concerns about scalability.",
"strengths": ["Innovative approach to coral reef monitoring", "Strong team expertise in marine biology"],
"weaknesses": ["Limited scalability plan", "Budget projections need more detail"],
"themes": [{"theme": "Innovation", "sentiment": "positive", "frequency": 3}, {"theme": "Scalability", "sentiment": "negative", "frequency": 2}],
"recommendation": "Recommended for advancement with condition to address scalability concerns in next round."
}
Guidelines: Guidelines:
- Base your analysis only on the provided evaluation data - Base your analysis only on the provided evaluation data
- For numeric criteria, consider score averages and distribution
- For yes/no criteria, consider the proportion of yes vs no answers
- For text criteria, synthesize common themes from the responses
- Identify common themes across evaluator feedback - Identify common themes across evaluator feedback
- Note areas of agreement and disagreement - Note areas of agreement and disagreement
- Keep the assessment objective and balanced - Keep the assessment objective and balanced
@@ -172,19 +198,63 @@ export function computeScoringPatterns(
consensus = Math.max(0, 1 - stdDev / 4.5) consensus = Math.max(0, 1 - stdDev / 4.5)
} }
// Criterion averages // Criterion averages (numeric only)
const criterionAverages: Record<string, number> = {} const criterionAverages: Record<string, number> = {}
// Boolean criteria stats
const booleanCriteria: Record<string, BooleanStats> = {}
// Text responses
const textResponses: Record<string, string[]> = {}
for (const criterion of criteriaLabels) { for (const criterion of criteriaLabels) {
const scores: number[] = [] const type = criterion.type || 'numeric'
for (const ev of evaluations) {
const criterionScores = ev.criterionScoresJson as Record<string, number> | null if (type === 'numeric') {
if (criterionScores && criterionScores[criterion.id] !== undefined) { const scores: number[] = []
scores.push(criterionScores[criterion.id]) for (const ev of evaluations) {
const criterionScores = ev.criterionScoresJson as Record<string, number | boolean | string> | null
const val = criterionScores?.[criterion.id]
if (typeof val === 'number') {
scores.push(val)
}
}
if (scores.length > 0) {
criterionAverages[criterion.label] =
scores.reduce((a, b) => a + b, 0) / scores.length
}
} else if (type === 'boolean') {
let yesCount = 0
let noCount = 0
for (const ev of evaluations) {
const criterionScores = ev.criterionScoresJson as Record<string, number | boolean | string> | null
const val = criterionScores?.[criterion.id]
if (typeof val === 'boolean') {
if (val) yesCount++
else noCount++
}
}
const total = yesCount + noCount
if (total > 0) {
booleanCriteria[criterion.label] = {
yesCount,
noCount,
total,
yesPercent: Math.round((yesCount / total) * 100),
trueLabel: criterion.trueLabel || 'Yes',
falseLabel: criterion.falseLabel || 'No',
}
}
} else if (type === 'text') {
const responses: string[] = []
for (const ev of evaluations) {
const criterionScores = ev.criterionScoresJson as Record<string, number | boolean | string> | null
const val = criterionScores?.[criterion.id]
if (typeof val === 'string' && val.trim()) {
responses.push(sanitizeText(val))
}
}
if (responses.length > 0) {
textResponses[criterion.label] = responses
} }
}
if (scores.length > 0) {
criterionAverages[criterion.label] =
scores.reduce((a, b) => a + b, 0) / scores.length
} }
} }
@@ -192,6 +262,8 @@ export function computeScoringPatterns(
averageGlobalScore, averageGlobalScore,
consensus: Math.round(consensus * 100) / 100, consensus: Math.round(consensus * 100) / 100,
criterionAverages, criterionAverages,
booleanCriteria,
textResponses,
evaluatorCount: evaluations.length, evaluatorCount: evaluations.length,
} }
} }
@@ -266,7 +338,6 @@ export async function generateSummary({
const criteria: CriterionDef[] = form?.criteriaJson const criteria: CriterionDef[] = form?.criteriaJson
? (form.criteriaJson as unknown as CriterionDef[]) ? (form.criteriaJson as unknown as CriterionDef[])
: [] : []
const criteriaLabels = criteria.map((c) => c.label)
// 2. Anonymize evaluations // 2. Anonymize evaluations
const typedEvaluations = evaluations as unknown as EvaluationForSummary[] const typedEvaluations = evaluations as unknown as EvaluationForSummary[]
@@ -282,7 +353,7 @@ export async function generateSummary({
} }
const model = await getConfiguredModel(AI_MODELS.QUICK) const model = await getConfiguredModel(AI_MODELS.QUICK)
const prompt = buildSummaryPrompt(anonymized, project.title, criteriaLabels) const prompt = buildSummaryPrompt(anonymized, project.title, criteria)
let aiResponse: AIResponsePayload let aiResponse: AIResponsePayload
let tokensUsed = 0 let tokensUsed = 0