feat: per-category evaluation criteria (startup vs business concept)
Add ability to define completely different evaluation criteria for each competition category. Admins toggle "Separate Criteria per Category" in round config, then configure criteria independently via tabbed editor. - Schema: add nullable `category` to EvaluationForm with updated constraints - Config: add `perCategoryCriteria` boolean to EvaluationConfigSchema - Helper: new `findActiveForm()` with category-aware resolution + fallback - Backend: getForm, upsertForm, getStageForm, startStage all category-aware - AI services: use project category for form lookup in summaries + ranking - Export/ranking: merge criteria from all active forms for cross-category reports - Admin UI: toggle switch + tabbed criteria editor with per-category builders - Jury UI: auto-selects correct form based on project category (invisible to juror) - Fully backwards compatible: toggle defaults OFF, existing forms unchanged Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -103,14 +103,23 @@ export const exportRouter = router({
|
||||
projectScores: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Fetch evaluation form to get criteria labels
|
||||
const evalForm = await ctx.prisma.evaluationForm.findFirst({
|
||||
// Fetch all active evaluation forms for this round (shared + category-specific)
|
||||
const activeForms = await ctx.prisma.evaluationForm.findMany({
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
select: { criteriaJson: true },
|
||||
})
|
||||
const criteria = (evalForm?.criteriaJson as Array<{
|
||||
id: string; label: string; type?: string
|
||||
}> | null) ?? []
|
||||
// Merge criteria across all forms, deduplicating by criterion id
|
||||
const seenCriterionIds = new Set<string>()
|
||||
const criteria: Array<{ id: string; label: string; type?: string }> = []
|
||||
for (const f of activeForms) {
|
||||
const fc = (f.criteriaJson as Array<{ id: string; label: string; type?: string }> | null) ?? []
|
||||
for (const c of fc) {
|
||||
if (!seenCriterionIds.has(c.id)) {
|
||||
seenCriterionIds.add(c.id)
|
||||
criteria.push(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
const numericCriteria = criteria.filter((c) => !c.type || c.type === 'numeric')
|
||||
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
@@ -632,12 +641,24 @@ export const exportRouter = router({
|
||||
|
||||
// Criteria breakdown
|
||||
if (includeSection('criteriaBreakdown')) {
|
||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
||||
// Fetch all active forms (shared + category-specific) and merge criteria
|
||||
const allForms = await ctx.prisma.evaluationForm.findMany({
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
})
|
||||
|
||||
if (form?.criteriaJson) {
|
||||
const criteria = form.criteriaJson as Array<{ id: string; label: string }>
|
||||
const seenIds = new Set<string>()
|
||||
const allCriteria: Array<{ id: string; label: string }> = []
|
||||
for (const f of allForms) {
|
||||
const fc = (f.criteriaJson as Array<{ id: string; label: string }> | null) ?? []
|
||||
for (const c of fc) {
|
||||
if (!seenIds.has(c.id)) {
|
||||
seenIds.add(c.id)
|
||||
allCriteria.push(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allCriteria.length > 0) {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
@@ -646,7 +667,7 @@ export const exportRouter = router({
|
||||
select: { criterionScoresJson: true },
|
||||
})
|
||||
|
||||
result.criteriaBreakdown = criteria.map((c) => {
|
||||
result.criteriaBreakdown = allCriteria.map((c) => {
|
||||
const scores: number[] = []
|
||||
evaluations.forEach((e) => {
|
||||
const cs = e.criterionScoresJson as Record<string, number> | null
|
||||
|
||||
Reference in New Issue
Block a user