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

@@ -160,13 +160,49 @@ function anonymizeProjectsForRanking(
}
/**
* Compute pass rate from Evaluation records.
* Handles both legacy binaryDecision boolean and future dedicated field.
* Falls back to binaryDecision if no future field exists.
* Find the boolean criterion ID for "Move to the Next Stage?" from round config.
* Returns null if no such criterion exists.
*/
function computePassRate(evaluations: Array<{ binaryDecision: boolean | null }>): number {
function findBooleanCriterionId(roundConfig: Record<string, unknown> | null): string | null {
if (!roundConfig) return null
const criteria = (roundConfig.criteria ?? roundConfig.evaluationCriteria ?? []) as Array<{
id: string
label: string
type?: string
}>
const boolCriterion = criteria.find(
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
)
return boolCriterion?.id ?? null
}
/**
* Resolve the binary advance decision for an evaluation.
* 1. Use binaryDecision column if set
* 2. Fall back to the boolean criterion in criterionScoresJson
*/
function resolveBinaryDecision(
binaryDecision: boolean | null,
criterionScoresJson: Record<string, unknown> | null,
boolCriterionId: string | null,
): boolean | null {
if (binaryDecision != null) return binaryDecision
if (!boolCriterionId || !criterionScoresJson) return null
const value = criterionScoresJson[boolCriterionId]
if (typeof value === 'boolean') return value
if (value === 'true') return true
if (value === 'false') return false
return null
}
/**
* Compute pass rate from Evaluation records.
* Counts evaluations where the advance decision resolved to true.
* Evaluations with null decision are treated as "no" (not counted as pass).
*/
function computePassRate(evaluations: Array<{ resolvedDecision: boolean | null }>): number {
if (evaluations.length === 0) return 0
const passCount = evaluations.filter((e) => e.binaryDecision === true).length
const passCount = evaluations.filter((e) => e.resolvedDecision === true).length
return passCount / evaluations.length
}
@@ -377,6 +413,13 @@ export async function fetchAndRankCategory(
prisma: PrismaClient,
userId?: string,
): Promise<RankingResult> {
// Fetch the round config to find the boolean criterion ID (legacy fallback)
const round = await prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { configJson: true },
})
const boolCriterionId = findBooleanCriterionId(round.configJson as Record<string, unknown> | null)
// Query submitted evaluations grouped by projectId for this category
const assignments = await prisma.assignment.findMany({
where: {
@@ -395,7 +438,7 @@ export async function fetchAndRankCategory(
},
include: {
evaluation: {
select: { globalScore: true, binaryDecision: true },
select: { globalScore: true, binaryDecision: true, criterionScoresJson: true },
},
project: {
select: { id: true, competitionCategory: true },
@@ -403,12 +446,17 @@ export async function fetchAndRankCategory(
},
})
// Group by projectId
const byProject = new Map<string, Array<{ globalScore: number | null; binaryDecision: boolean | null }>>()
// Group by projectId, resolving binaryDecision from column or criterionScoresJson fallback
const byProject = new Map<string, Array<{ globalScore: number | null; resolvedDecision: boolean | null }>>()
for (const a of assignments) {
if (!a.evaluation) continue
const resolved = resolveBinaryDecision(
a.evaluation.binaryDecision,
a.evaluation.criterionScoresJson as Record<string, unknown> | null,
boolCriterionId,
)
const list = byProject.get(a.project.id) ?? []
list.push({ globalScore: a.evaluation.globalScore, binaryDecision: a.evaluation.binaryDecision })
list.push({ globalScore: a.evaluation.globalScore, resolvedDecision: resolved })
byProject.set(a.project.id, list)
}