feat: add advance criterion type to evaluation form builder

Adds a new 'advance' criterion type representing "should this project advance to the next round?". Only one advance criterion is allowed per form (button disabled once added). No weight, no condition fields, always required. Also updates the upsertForm Zod schema to accept the new type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 15:04:58 +01:00
parent bf86eeee7f
commit 0edb50cd3a
2 changed files with 63 additions and 7 deletions

View File

@@ -51,10 +51,11 @@ import {
Heading,
ThumbsUp,
ThumbsDown,
ArrowUpCircle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
export type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
export type CriterionType = 'numeric' | 'text' | 'boolean' | 'advance' | 'section_header'
export interface CriterionCondition {
criterionId: string
@@ -107,6 +108,8 @@ function createDefaultCriterion(type: CriterionType = 'numeric'): Criterion {
return { ...base, maxLength: 1000, placeholder: '', required: true }
case 'boolean':
return { ...base, trueLabel: 'Yes', falseLabel: 'No', required: true }
case 'advance':
return { ...base, label: 'Advance to next round?', trueLabel: 'Yes', falseLabel: 'No', required: true }
case 'section_header':
return { ...base, required: false }
default:
@@ -236,7 +239,7 @@ export function EvaluationFormBuilder({
{/* Type indicator */}
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{CRITERION_TYPE_OPTIONS.find((t) => t.value === (editDraft.type || 'numeric'))?.label ?? 'Numeric Score'}
{editDraft.type === 'advance' ? 'Advance to Next Round?' : (CRITERION_TYPE_OPTIONS.find((t) => t.value === (editDraft.type || 'numeric'))?.label ?? 'Numeric Score')}
</Badge>
</div>
@@ -413,8 +416,33 @@ export function EvaluationFormBuilder({
</div>
)}
{/* Condition builder - available for all types except section_header */}
{(editDraft.type || 'numeric') !== 'section_header' && criteria.filter((c) => c.id !== editDraft.id).length > 0 && (
{editDraft.type === 'advance' && (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`trueLabel-${criterion.id}`}>Yes Label</Label>
<Input
id={`trueLabel-${criterion.id}`}
value={editDraft.trueLabel || 'Yes'}
onChange={(e) => updateDraft({ trueLabel: e.target.value })}
placeholder="Yes"
disabled={disabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`falseLabel-${criterion.id}`}>No Label</Label>
<Input
id={`falseLabel-${criterion.id}`}
value={editDraft.falseLabel || 'No'}
onChange={(e) => updateDraft({ falseLabel: e.target.value })}
placeholder="No"
disabled={disabled}
/>
</div>
</div>
)}
{/* Condition builder - available for all types except section_header and advance */}
{(editDraft.type || 'numeric') !== 'section_header' && editDraft.type !== 'advance' && criteria.filter((c) => c.id !== editDraft.id).length > 0 && (
<div className="space-y-2 border-t pt-4">
<div className="flex items-center justify-between">
<Label>Conditional Visibility</Label>
@@ -554,11 +582,11 @@ export function EvaluationFormBuilder({
</span>
{(() => {
const type = criterion.type || 'numeric'
const TypeIcon = CRITERION_TYPE_OPTIONS.find((t) => t.value === type)?.icon ?? Hash
const TypeIcon = type === 'advance' ? ArrowUpCircle : (CRITERION_TYPE_OPTIONS.find((t) => t.value === type)?.icon ?? Hash)
return (
<Badge variant="secondary" className="shrink-0 text-xs gap-1">
<TypeIcon className="h-3 w-3" />
{type === 'numeric' ? `1-${criterion.scale ?? 5}` : CRITERION_TYPE_OPTIONS.find((t) => t.value === type)?.label}
{type === 'numeric' ? `1-${criterion.scale ?? 5}` : type === 'advance' ? 'Advance to Next Round?' : CRITERION_TYPE_OPTIONS.find((t) => t.value === type)?.label}
</Badge>
)
})()}
@@ -684,6 +712,22 @@ export function EvaluationFormBuilder({
</Button>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => addCriterion('advance')}
disabled={editingId !== null || criteria.some((c) => c.type === 'advance')}
title={criteria.some((c) => c.type === 'advance') ? 'Only one advance criterion allowed per form' : undefined}
className={cn(
'border-brand-blue/40 text-brand-blue hover:bg-brand-blue/5',
criteria.some((c) => c.type === 'advance') && 'opacity-50 cursor-not-allowed'
)}
>
<ArrowUpCircle className="mr-1 h-4 w-4" />
Advance to Next Round?
</Button>
{criteria.length > 0 && (
<PreviewDialog criteria={criteria} />
)}
@@ -796,6 +840,18 @@ function PreviewDialog({ criteria }: { criteria: Criterion[] }) {
</div>
</div>
)}
{type === 'advance' && (
<div className="flex gap-4">
<div className="flex-1 h-14 rounded-lg border-2 border-emerald-300 bg-emerald-50/50 flex items-center justify-center text-sm font-semibold text-emerald-700">
<ThumbsUp className="mr-2 h-4 w-4" />
{criterion.trueLabel || 'Yes'}
</div>
<div className="flex-1 h-14 rounded-lg border-2 border-red-300 bg-red-50/50 flex items-center justify-center text-sm font-semibold text-red-700">
<ThumbsDown className="mr-2 h-4 w-4" />
{criterion.falseLabel || 'No'}
</div>
</div>
)}
</CardContent>
</Card>
)

View File

@@ -1227,7 +1227,7 @@ 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(),
type: z.enum(['numeric', 'text', 'boolean', 'advance', 'section_header']).optional(),
// Numeric fields
weight: z.number().min(0).max(100).optional(),
minScore: z.number().int().min(0).optional(),