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

@@ -24,7 +24,9 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Loader2, Mail, ArrowRightLeft, UserPlus, Trash2 } from 'lucide-react'
import { Loader2, Mail, ArrowRightLeft, UserPlus, Trash2, FilePen } from 'lucide-react'
import Link from 'next/link'
import type { Route } from 'next'
import { TransferAssignmentsDialog } from './transfer-assignments-dialog'
import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap'
@@ -186,6 +188,24 @@ export function JuryProgressTable({
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
asChild
>
<Link href={`/admin/rounds/${roundId}/jurors/${juror.id}/evaluate` as Route}>
<FilePen className="h-3 w-3" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Fill in evaluations on behalf of this juror</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -0,0 +1,432 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { cn } from '@/lib/utils'
import { ThumbsUp, ThumbsDown, CheckCircle2 } from 'lucide-react'
export type EvaluationCriterion = {
id: string
label: string
description?: string | null
type: 'numeric' | 'text' | 'boolean' | 'advance' | 'section_header'
weight?: number | null
minScore: number
maxScore: number
required: boolean
trueLabel: string
falseLabel: string
maxLength: number
placeholder: string
}
export type ScoringMode = 'criteria' | 'global' | 'binary'
export type EvaluationFormFieldsProps = {
criteria: EvaluationCriterion[]
scoringMode: ScoringMode
requireFeedback: boolean
feedbackMinLength: number
criteriaValues: Record<string, number | boolean | string>
globalScore: string
binaryDecision: '' | 'accept' | 'reject'
feedbackText: string
isReadOnly: boolean
lastSavedAt?: Date | null
headerDescription?: string
onCriterionChange: (key: string, value: number | boolean | string) => void
onGlobalScoreChange: (value: string) => void
onBinaryChange: (value: 'accept' | 'reject') => void
onFeedbackChange: (value: string) => void
}
/**
* Parse an EvaluationForm.criteriaJson payload into typed criteria for display.
* Kept in this file so every consumer normalizes in the same way.
*/
export function parseCriteriaFromForm(
criteriaJson: ReadonlyArray<Record<string, unknown>> | null | undefined,
): EvaluationCriterion[] {
if (!criteriaJson) return []
return criteriaJson.map((raw) => {
const c = raw as Record<string, unknown>
const type = (c.type as EvaluationCriterion['type']) || 'numeric'
let minScore = 1
let maxScore = 10
if (type === 'numeric' && typeof c.scale === 'string') {
const parts = c.scale.split('-').map(Number)
if (parts.length === 2 && !Number.isNaN(parts[0]) && !Number.isNaN(parts[1])) {
minScore = parts[0]
maxScore = parts[1]
}
}
return {
id: c.id as string,
label: c.label as string,
description: (c.description as string | null | undefined) ?? null,
type,
weight: (c.weight as number | null | undefined) ?? null,
minScore,
maxScore,
required: (c.required as boolean | undefined) ?? true,
trueLabel: (c.trueLabel as string | undefined) || 'Yes',
falseLabel: (c.falseLabel as string | undefined) || 'No',
maxLength: (c.maxLength as number | undefined) || 1000,
placeholder: (c.placeholder as string | undefined) || '',
}
})
}
export function EvaluationFormFields({
criteria,
scoringMode,
requireFeedback,
feedbackMinLength,
criteriaValues,
globalScore,
binaryDecision,
feedbackText,
isReadOnly,
lastSavedAt,
headerDescription,
onCriterionChange,
onGlobalScoreChange,
onBinaryChange,
onFeedbackChange,
}: EvaluationFormFieldsProps) {
const description =
headerDescription ??
(scoringMode === 'criteria'
? 'Complete all required fields below'
: `Provide your assessment using the ${scoringMode} scoring method`)
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Evaluation Form</CardTitle>
<CardDescription>{description}</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">
{scoringMode === '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>
)
}
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={() => onCriterionChange(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={() => onCriterionChange(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={() => onCriterionChange(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={() => onCriterionChange(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) => onCriterionChange(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 : '—'}/{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) => onCriterionChange(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={() => onCriterionChange(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>
)}
{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 || '—'}/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) => onGlobalScoreChange(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={() => onGlobalScoreChange(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>
)}
{scoringMode === 'binary' && (
<div className="space-y-2">
<Label>
Decision <span className="text-destructive">*</span>
</Label>
<RadioGroup
value={binaryDecision}
onValueChange={(v) => onBinaryChange(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 &mdash; 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 &mdash; This project should not advance</span>
</Label>
</div>
</RadioGroup>
</div>
)}
<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) => onFeedbackChange(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>
)
}