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

@@ -108,6 +108,8 @@ import { AnimatedCard } from '@/components/shared/animated-container'
import { DateTimePicker } from '@/components/ui/datetime-picker'
import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog'
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 ──────────────────────────────────────────────
const roundStatusConfig = {
@@ -3118,12 +3120,9 @@ function AIRecommendationsDisplay({
// ── Evaluation Criteria Editor ───────────────────────────────────────────
function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
const [editing, setEditing] = useState(false)
const [criteria, setCriteria] = useState<Array<{
id: string; label: string; description?: string; weight?: number; minScore?: number; maxScore?: number
}>>([])
const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
const utils = trpc.useUtils()
const { data: form, isLoading } = trpc.evaluation.getForm.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
@@ -3133,49 +3132,59 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
onSuccess: () => {
utils.evaluation.getForm.invalidate({ roundId })
toast.success('Evaluation criteria saved')
setEditing(false)
setPendingCriteria(null)
},
onError: (err) => toast.error(err.message),
})
// Sync from server
if (form && !editing) {
const serverCriteria = form.criteriaJson ?? []
if (JSON.stringify(serverCriteria) !== JSON.stringify(criteria)) {
setCriteria(serverCriteria)
}
}
// Convert server criteriaJson to Criterion[] format
const serverCriteria: Criterion[] = useMemo(() => {
if (!form?.criteriaJson) return []
return (form.criteriaJson as Criterion[]).map((c) => {
// 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 = () => {
setCriteria([...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 handleChange = useCallback((criteria: Criterion[]) => {
setPendingCriteria(criteria)
}, [])
const handleSave = () => {
const criteria = pendingCriteria ?? serverCriteria
const validCriteria = criteria.filter((c) => c.label.trim())
if (validCriteria.length === 0) {
toast.error('Add at least one criterion')
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 (
@@ -3185,30 +3194,22 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
<div>
<CardTitle className="text-base">Evaluation Criteria</CardTitle>
<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>
</div>
<div className="flex items-center gap-2">
{editing && (
<Button size="sm" variant="outline" onClick={() => {
setEditing(false)
if (form) setCriteria(form.criteriaJson)
}}>
{pendingCriteria && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setPendingCriteria(null)}>
Cancel
</Button>
)}
{editing ? (
<Button size="sm" onClick={handleSave} disabled={upsertMutation.isPending}>
{upsertMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Save Criteria
</Button>
) : (
<Button size="sm" variant="outline" onClick={handleAdd}>
<Plus className="h-4 w-4 mr-1.5" />
Add Criterion
</Button>
)}
</div>
</div>
)}
</div>
</CardHeader>
<CardContent>
@@ -3216,83 +3217,11 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
<div className="space-y-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
</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">
{criteria.map((c, idx) => (
<div key={c.id} className="flex gap-3 items-start p-3 border rounded-lg bg-muted/20">
<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>
<EvaluationFormBuilder
initialCriteria={serverCriteria}
onChange={handleChange}
/>
)}
</CardContent>
</Card>