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

@@ -465,4 +465,37 @@ export const dashboardRouter = router({
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(),
label: z.string().min(1).max(255),
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(),
minScore: z.number().int().min(0).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),
})
@@ -1088,18 +1106,46 @@ export const evaluationRouter = router({
})
const nextVersion = (latestForm?.version ?? 0) + 1
// Build criteriaJson with defaults
const criteriaJson = criteria.map((c) => ({
id: c.id,
label: c.label,
description: c.description || '',
weight: c.weight ?? 1,
scale: `${c.minScore ?? 1}-${c.maxScore ?? 10}`,
required: true,
}))
// Build criteriaJson preserving all fields
const criteriaJson = criteria.map((c) => {
const type = c.type || 'numeric'
const base = {
id: c.id,
label: c.label,
description: c.description || '',
type,
required: c.required ?? (type !== 'section_header'),
}
// Auto-generate scalesJson from criteria min/max ranges
const scaleSet = new Set(criteriaJson.map((c) => c.scale))
if (type === 'numeric') {
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 }> = {}
for (const scale of scaleSet) {
const [min, max] = scale.split('-').map(Number)

View File

@@ -1141,7 +1141,8 @@ export const projectRouter = router({
where: { projectId: input.id },
include: {
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' },
}),