feat: admin can fill in evaluations on behalf of jurors
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m54s

When a juror cannot connect during an evaluation round, an admin
can now submit evaluations for them.

Router — new admin procedures:
- adminStart / adminAutosave: create and save drafts for any juror.
- adminSubmitOnBehalf: submit bypassing ROUND_ACTIVE and voting-window
  checks. COI block and feedback/criterion validation still enforced.
  Audit log records both admin and juror IDs plus bypassedWindow flag.
- getJurorAssignmentsForRound: list a juror's assignments + eval state.

UI — two new admin pages under /admin/rounds/[roundId]/jurors/[userId]/:
- evaluate: list of pending + completed assignments, COI flagged.
- evaluate/[projectId]: evaluation form reusing the juror's scoring UI,
  with an "acting on behalf" banner and confirmation dialog before
  submit. Back button returns to the assignments list.

Entry point: FilePen icon on each juror row in JuryProgressTable.

Refactor: extracted the scoring form JSX into shared
EvaluationFormFields component so the juror page and the admin proxy
page render identical inputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-21 16:41:14 +02:00
parent fd4f6dde16
commit 9cb3b9de13
6 changed files with 1547 additions and 358 deletions

View File

@@ -5,20 +5,19 @@ import { useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { cn } from '@/lib/utils'
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
import { Badge } from '@/components/ui/badge'
import { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog'
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert, Lock } from 'lucide-react'
import { ArrowLeft, Save, Send, AlertCircle, Clock, ShieldAlert, Lock } from 'lucide-react'
import { toast } from 'sonner'
import type { EvaluationConfig } from '@/types/competition-configs'
import {
EvaluationFormFields,
parseCriteriaFromForm,
} from '@/components/evaluation/evaluation-form-fields'
type PageProps = {
params: Promise<{ roundId: string; projectId: string }>
@@ -148,33 +147,10 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const requireFeedback = evalConfig?.requireFeedback ?? true
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
// Parse criteria from the active form
const criteria = (activeForm?.criteriaJson ?? []).map((c) => {
const type = (c as any).type || 'numeric'
let minScore = 1
let maxScore = 10
if (type === 'numeric' && c.scale) {
const parts = c.scale.split('-').map(Number)
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
minScore = parts[0]
maxScore = parts[1]
}
}
return {
id: c.id,
label: c.label,
description: c.description,
type: type as 'numeric' | 'text' | 'boolean' | 'advance' | 'section_header',
weight: c.weight,
minScore,
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 || '',
}
})
// Parse criteria from the active form (shared with admin proxy flow)
const criteria = parseCriteriaFromForm(
activeForm?.criteriaJson as ReadonlyArray<Record<string, unknown>> | null | undefined,
)
// Initialize numeric criteria with midpoint values so slider visual matches stored value.
const criteriaInitializedRef = useRef(false)
@@ -650,330 +626,23 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</Card>
)}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Evaluation Form</CardTitle>
<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>
<CardContent className="space-y-6">
{/* Criteria-based scoring with mixed types */}
{scoringMode === 'criteria' && criteria && criteria.length > 0 && (
<div className="space-y-4">
{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>
)
}
<EvaluationFormFields
criteria={criteria}
scoringMode={scoringMode}
requireFeedback={requireFeedback}
feedbackMinLength={feedbackMinLength}
criteriaValues={criteriaValues}
globalScore={globalScore}
binaryDecision={binaryDecision}
feedbackText={feedbackText}
isReadOnly={isReadOnly}
lastSavedAt={lastSavedAt}
onCriterionChange={handleCriterionChange}
onGlobalScoreChange={handleGlobalScoreChange}
onBinaryChange={handleBinaryChange}
onFeedbackChange={handleFeedbackChange}
/>
if (criterion.type === 'advance') {
const currentValue = criteriaValues[criterion.id]
return (
<div key={criterion.id} className="space-y-3 p-5 border-2 border-brand-blue/30 rounded-xl bg-brand-blue/5">
<div className="space-y-1">
<Label className="text-base font-semibold text-brand-blue">
{criterion.label}
<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-4">
<button
type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, true)}
className={cn(
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
currentValue === true
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
isReadOnly && 'opacity-60 cursor-default'
)}
>
<ThumbsUp className="mr-2 h-5 w-5" />
{criterion.trueLabel || 'Yes'}
</button>
<button
type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, false)}
className={cn(
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
currentValue === false
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200'
: 'border-border hover:border-red-300 hover:bg-red-50/50',
isReadOnly && 'opacity-60 cursor-default'
)}
>
<ThumbsDown className="mr-2 h-5 w-5" />
{criterion.falseLabel || 'No'}
</button>
</div>
</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"
disabled={isReadOnly}
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',
isReadOnly && 'opacity-60 cursor-default'
)}
>
<ThumbsUp className="mr-2 h-4 w-4" />
{criterion.trueLabel}
</button>
<button
type="button"
disabled={isReadOnly}
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',
isReadOnly && 'opacity-60 cursor-default'
)}
>
<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}
disabled={isReadOnly}
/>
<p className="text-xs text-muted-foreground text-right">
{currentValue.length}/{criterion.maxLength}
</p>
</div>
)
}
// Default: numeric criterion
const min = criterion.minScore ?? 1
const max = criterion.maxScore ?? 10
const currentValue = criteriaValues[criterion.id]
const displayValue = typeof currentValue === 'number' ? currentValue : undefined
const sliderValue = typeof currentValue === 'number' ? currentValue : Math.ceil((min + max) / 2)
return (
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
<div className="flex items-start justify-between gap-4">
<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>
<span className="shrink-0 rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
{displayValue !== undefined ? displayValue : '\u2014'}/{max}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-4">{min}</span>
<Slider
min={min}
max={max}
step={1}
value={[sliderValue]}
onValueChange={(v) => handleCriterionChange(criterion.id, v[0])}
className="flex-1"
disabled={isReadOnly}
/>
<span className="text-xs text-muted-foreground w-4">{max}</span>
</div>
<div className="flex gap-1 flex-wrap">
{Array.from({ length: max - min + 1 }, (_, i) => i + min).map((num) => (
<button
key={num}
type="button"
disabled={isReadOnly}
onClick={() => handleCriterionChange(criterion.id, num)}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
displayValue !== undefined && displayValue === num
? 'bg-primary text-primary-foreground'
: displayValue !== undefined && displayValue > num
? 'bg-primary/20 text-primary'
: 'bg-muted hover:bg-muted/80',
isReadOnly && 'cursor-default'
)}
>
{num}
</button>
))}
</div>
</div>
)
})}
</div>
)}
{/* Global scoring */}
{scoringMode === 'global' && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>
Overall Score <span className="text-destructive">*</span>
</Label>
<span className="rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
{globalScore || '\u2014'}/10
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">1</span>
<Slider
min={1}
max={10}
step={1}
value={[globalScore ? parseInt(globalScore, 10) : 5]}
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
className="flex-1"
disabled={isReadOnly}
/>
<span className="text-xs text-muted-foreground">10</span>
</div>
<div className="flex gap-1 flex-wrap">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => {
const current = globalScore ? parseInt(globalScore, 10) : 0
return (
<button
key={num}
type="button"
disabled={isReadOnly}
onClick={() => handleGlobalScoreChange(num.toString())}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
current === num
? 'bg-primary text-primary-foreground'
: current > num
? 'bg-primary/20 text-primary'
: 'bg-muted hover:bg-muted/80',
isReadOnly && 'cursor-default'
)}
>
{num}
</button>
)
})}
</div>
</div>
)}
{/* Binary decision */}
{scoringMode === 'binary' && (
<div className="space-y-2">
<Label>
Decision <span className="text-destructive">*</span>
</Label>
<RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')} disabled={isReadOnly}>
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
<RadioGroupItem value="accept" id="accept" />
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
<ThumbsUp className="h-4 w-4 text-emerald-600" />
<span>Accept This project should advance</span>
</Label>
</div>
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-red-50/50">
<RadioGroupItem value="reject" id="reject" />
<Label htmlFor="reject" className="flex items-center gap-2 cursor-pointer flex-1">
<ThumbsDown className="h-4 w-4 text-red-600" />
<span>Reject This project should not advance</span>
</Label>
</div>
</RadioGroup>
</div>
)}
{/* Feedback */}
<div className="space-y-2">
<Label htmlFor="feedbackText">
General Comment / Feedback
{requireFeedback && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id="feedbackText"
value={feedbackText}
onChange={(e) => handleFeedbackChange(e.target.value)}
placeholder="Provide your feedback on the project..."
rows={8}
disabled={isReadOnly}
/>
{requireFeedback && (
<p className="text-xs text-muted-foreground">
Minimum {feedbackMinLength} characters ({feedbackText.length}/{feedbackMinLength})
</p>
)}
</div>
</CardContent>
</Card>
{isReadOnly ? (
<div className="flex items-center">