feat: admin can fill in evaluations on behalf of jurors
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m54s
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:
@@ -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>
|
||||
|
||||
432
src/components/evaluation/evaluation-form-fields.tsx
Normal file
432
src/components/evaluation/evaluation-form-fields.tsx
Normal 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 — 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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user