feat: admin evaluation editing, ranking improvements, status transition fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m26s

- Add adminEditEvaluation mutation and getJurorEvaluations query
- Create shared EvaluationEditSheet component with inline feedback editing
- Add Evaluations tab to member detail page (grouped by round)
- Make jury group member names clickable (link to member detail)
- Replace inline EvaluationDetailSheet on project page with shared component
- Fix project status transition validation (skip when status unchanged)
- Fix frontend to not send status when unchanged on project edit
- Ranking dashboard improvements and boolean decision converter fixes
- Backfill script updates for binary decisions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 10:46:52 +01:00
parent 49e706f2cf
commit c6ebd169dd
11 changed files with 857 additions and 245 deletions

View File

@@ -1780,4 +1780,87 @@ export const evaluationRouter = router({
submissions,
}
}),
/**
* Admin: edit the feedbackText on a submitted evaluation.
*/
adminEditEvaluation: adminProcedure
.input(
z.object({
evaluationId: z.string(),
feedbackText: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const evaluation = await ctx.prisma.evaluation.findUnique({
where: { id: input.evaluationId },
select: { id: true, feedbackText: true },
})
if (!evaluation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Evaluation not found' })
}
const updated = await ctx.prisma.evaluation.update({
where: { id: input.evaluationId },
data: { feedbackText: input.feedbackText },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'ADMIN_EDIT_EVALUATION_FEEDBACK',
entityType: 'Evaluation',
entityId: input.evaluationId,
detailsJson: {
adminUserId: ctx.user.id,
before: (evaluation.feedbackText ?? '').slice(0, 200),
after: input.feedbackText.slice(0, 200),
},
})
return updated
}),
/**
* Admin: get all evaluations submitted by a specific juror.
*/
getJurorEvaluations: adminProcedure
.input(z.object({ userId: z.string() }))
.query(async ({ ctx, input }) => {
const assignments = await ctx.prisma.assignment.findMany({
where: { userId: input.userId },
include: {
project: { select: { id: true, title: true } },
round: { select: { id: true, name: true, roundType: true, sortOrder: true } },
evaluation: {
select: {
id: true,
globalScore: true,
binaryDecision: true,
feedbackText: true,
status: true,
submittedAt: true,
criterionScoresJson: true,
},
},
},
orderBy: [
{ round: { sortOrder: 'asc' } },
{ project: { title: 'asc' } },
],
})
return assignments
.filter((a) => a.evaluation !== null)
.map((a) => ({
assignmentId: a.id,
roundId: a.roundId,
roundName: a.round.name,
roundType: a.round.roundType,
projectId: a.project.id,
projectTitle: a.project.title,
evaluation: a.evaluation!,
}))
}),
})

View File

@@ -721,18 +721,20 @@ export const projectRouter = router({
? (country === null ? null : normalizeCountryToCode(country))
: undefined
// Validate status transition if status is being changed
// Validate status transition if status is actually changing
if (status) {
const currentProject = await ctx.prisma.project.findUniqueOrThrow({
where: { id },
select: { status: true },
})
const allowedTransitions = VALID_PROJECT_TRANSITIONS[currentProject.status] || []
if (!allowedTransitions.includes(status)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Invalid status transition: cannot change from ${currentProject.status} to ${status}. Allowed: ${allowedTransitions.join(', ') || 'none'}`,
})
if (status !== currentProject.status) {
const allowedTransitions = VALID_PROJECT_TRANSITIONS[currentProject.status] || []
if (!allowedTransitions.includes(status)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Invalid status transition: cannot change from ${currentProject.status} to ${status}. Allowed: ${allowedTransitions.join(', ') || 'none'}`,
})
}
}
}

View File

@@ -387,4 +387,79 @@ export const rankingRouter = router({
triggered: results.filter((r) => r.triggered).length,
}
}),
/**
* Get per-project evaluation scores for a round.
* Returns a map of projectId → array of { jurorName, globalScore, binaryDecision }.
* Used by the ranking dashboard to show individual juror scores inline.
*/
roundEvaluationScores: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Fetch the round config to find the boolean criterion ID (legacy fallback)
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { configJson: true },
})
const roundConfig = round.configJson as Record<string, unknown> | null
const criteria = (roundConfig?.criteria ?? roundConfig?.evaluationCriteria ?? []) as Array<{
id: string
label: string
type?: string
}>
const boolCriterionId = criteria.find(
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
)?.id ?? null
const assignments = await ctx.prisma.assignment.findMany({
where: {
roundId: input.roundId,
isRequired: true,
evaluation: { status: 'SUBMITTED' },
},
select: {
projectId: true,
user: { select: { name: true, email: true } },
evaluation: {
select: {
globalScore: true,
binaryDecision: true,
criterionScoresJson: true,
},
},
},
})
const byProject: Record<string, Array<{
jurorName: string
globalScore: number | null
decision: boolean | null
}>> = {}
for (const a of assignments) {
if (!a.evaluation) continue
const list = byProject[a.projectId] ?? []
// Resolve binary decision: column first, then criterion fallback
let decision = a.evaluation.binaryDecision
if (decision == null && boolCriterionId) {
const scores = a.evaluation.criterionScoresJson as Record<string, unknown> | null
if (scores) {
const val = scores[boolCriterionId]
if (typeof val === 'boolean') decision = val
else if (val === 'true') decision = true
else if (val === 'false') decision = false
}
}
list.push({
jurorName: a.user.name ?? a.user.email ?? 'Unknown',
globalScore: a.evaluation.globalScore,
decision,
})
byProject[a.projectId] = list
}
return byProject
}),
})