feat: render advance criterion on juror evaluation page and fix related renderers

- Jury evaluate page: add prominent advance criterion block (h-14, brand-blue border) before boolean block, fix type cast to include 'advance', add advance to required-field validation
- evaluation-form.tsx: add 'advance' to CriterionType, schema, default values, progress tracking, rendering via new AdvanceCriterionField component with prominent styling
- Admin project detail: treat advance same as boolean in EvaluationDetailSheet criterion score display
- Observer project detail: treat advance same as boolean in evaluation criterion score display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 15:11:24 +01:00
parent 6c97ce3ed9
commit a327962f04
4 changed files with 148 additions and 8 deletions

View File

@@ -927,7 +927,7 @@ function EvaluationDetailSheet({
if (type === 'section_header') return null if (type === 'section_header') return null
if (type === 'boolean') { if (type === 'boolean' || type === 'advance') {
return ( return (
<div key={key} className="flex items-center justify-between p-2.5 rounded-lg border"> <div key={key} className="flex items-center justify-between p-2.5 rounded-lg border">
<span className="text-sm">{label}</span> <span className="text-sm">{label}</span>

View File

@@ -164,7 +164,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
id: c.id, id: c.id,
label: c.label, label: c.label,
description: c.description, description: c.description,
type: type as 'numeric' | 'text' | 'boolean' | 'section_header', type: type as 'numeric' | 'text' | 'boolean' | 'advance' | 'section_header',
weight: c.weight, weight: c.weight,
minScore, minScore,
maxScore, maxScore,
@@ -352,7 +352,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
setIsSubmitting(false) setIsSubmitting(false)
return return
} }
if (c.type === 'boolean' && val === undefined) { if ((c.type === 'boolean' || c.type === 'advance') && val === undefined) {
toast.error(`Please answer "${c.label}"`) toast.error(`Please answer "${c.label}"`)
isSubmittingRef.current = false isSubmittingRef.current = false
setIsSubmitting(false) setIsSubmitting(false)
@@ -657,6 +657,51 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
) )
} }
if (criterion.type === 'advance') {
const currentValue = criteriaValues[criterion.id]
return (
<div key={criterion.id} className="space-y-3 p-5 border-2 border-brand-blue/30 rounded-xl bg-brand-blue/5">
<div className="space-y-1">
<Label className="text-base font-semibold text-brand-blue">
{criterion.label}
<span className="text-destructive ml-1">*</span>
</Label>
{criterion.description && (
<p className="text-sm text-muted-foreground">{criterion.description}</p>
)}
</div>
<div className="flex gap-4">
<button
type="button"
onClick={() => handleCriterionChange(criterion.id, true)}
className={cn(
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
currentValue === true
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
)}
>
<ThumbsUp className="mr-2 h-5 w-5" />
{criterion.trueLabel || 'Yes'}
</button>
<button
type="button"
onClick={() => handleCriterionChange(criterion.id, false)}
className={cn(
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
currentValue === false
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200'
: 'border-border hover:border-red-300 hover:bg-red-50/50'
)}
>
<ThumbsDown className="mr-2 h-5 w-5" />
{criterion.falseLabel || 'No'}
</button>
</div>
</div>
)
}
if (criterion.type === 'boolean') { if (criterion.type === 'boolean') {
const currentValue = criteriaValues[criterion.id] const currentValue = criteriaValues[criterion.id]
return ( return (

View File

@@ -42,7 +42,7 @@ import { toast } from 'sonner'
import { CountdownTimer } from '@/components/shared/countdown-timer' import { CountdownTimer } from '@/components/shared/countdown-timer'
// Define criterion type from the evaluation form JSON // Define criterion type from the evaluation form JSON
type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header' type CriterionType = 'numeric' | 'text' | 'boolean' | 'advance' | 'section_header'
interface CriterionCondition { interface CriterionCondition {
criterionId: string criterionId: string
@@ -96,7 +96,7 @@ const createEvaluationSchema = (criteria: Criterion[]) => {
criterionFields[c.id] = z.number() criterionFields[c.id] = z.number()
} else if (type === 'text') { } else if (type === 'text') {
criterionFields[c.id] = z.string() criterionFields[c.id] = z.string()
} else if (type === 'boolean') { } else if (type === 'boolean' || type === 'advance') {
criterionFields[c.id] = z.boolean() criterionFields[c.id] = z.boolean()
} }
} }
@@ -180,7 +180,7 @@ export function EvaluationForm({
defaultCriterionScores[c.id] = typeof existing === 'number' ? existing : Math.ceil((c.scale ?? 5) / 2) defaultCriterionScores[c.id] = typeof existing === 'number' ? existing : Math.ceil((c.scale ?? 5) / 2)
} else if (type === 'text') { } else if (type === 'text') {
defaultCriterionScores[c.id] = typeof existing === 'string' ? existing : '' defaultCriterionScores[c.id] = typeof existing === 'string' ? existing : ''
} else if (type === 'boolean') { } else if (type === 'boolean' || type === 'advance') {
defaultCriterionScores[c.id] = typeof existing === 'boolean' ? existing : false defaultCriterionScores[c.id] = typeof existing === 'boolean' ? existing : false
} }
}) })
@@ -225,7 +225,7 @@ export function EvaluationForm({
} else if (type === 'text') { } else if (type === 'text') {
const val = watchedScores?.[c.id] const val = watchedScores?.[c.id]
if (typeof val === 'string' && val.length > 0) criteriaDone++ if (typeof val === 'string' && val.length > 0) criteriaDone++
} else if (type === 'boolean') { } else if (type === 'boolean' || type === 'advance') {
if (touchedCriteria.has(c.id)) criteriaDone++ if (touchedCriteria.has(c.id)) criteriaDone++
} }
} }
@@ -470,6 +470,19 @@ export function EvaluationForm({
) )
} }
// Advance (prominent boolean-valued criterion)
if (type === 'advance') {
return (
<AdvanceCriterionField
key={criterion.id}
criterion={criterion}
control={control}
disabled={isReadOnly}
onTouch={onCriterionTouch}
/>
)
}
return null return null
})} })}
</CardContent> </CardContent>
@@ -947,6 +960,88 @@ function BooleanCriterionField({
) )
} }
// Advance criterion field component (prominent boolean — "should this project advance?")
function AdvanceCriterionField({
criterion,
control,
disabled,
onTouch,
}: {
criterion: Criterion
control: any
disabled: boolean
onTouch: (criterionId: string) => void
}) {
const trueLabel = criterion.trueLabel || 'Yes'
const falseLabel = criterion.falseLabel || 'No'
return (
<Controller
name={`criterionScores.${criterion.id}`}
control={control}
render={({ field }) => (
<div className="space-y-3 p-5 border-2 border-brand-blue/30 rounded-xl bg-brand-blue/5">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label className="text-base font-semibold text-brand-blue">{criterion.label}</Label>
{criterion.required && (
<Badge variant="destructive" className="text-xs">Required</Badge>
)}
</div>
{criterion.description && (
<p className="text-sm text-muted-foreground">
{criterion.description}
</p>
)}
</div>
<div className="flex gap-4">
<Button
type="button"
variant={field.value === true ? 'default' : 'outline'}
className={cn(
'flex-1 h-14 rounded-xl border-2 text-base font-semibold transition-all',
field.value === true
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200 hover:bg-emerald-100'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
)}
onClick={() => {
if (!disabled) {
field.onChange(true)
onTouch(criterion.id)
}
}}
disabled={disabled}
>
<ThumbsUp className="mr-2 h-5 w-5" />
{trueLabel}
</Button>
<Button
type="button"
variant={field.value === false ? 'default' : 'outline'}
className={cn(
'flex-1 h-14 rounded-xl border-2 text-base font-semibold transition-all',
field.value === false
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200 hover:bg-red-100'
: 'border-border hover:border-red-300 hover:bg-red-50/50'
)}
onClick={() => {
if (!disabled) {
field.onChange(false)
onTouch(criterion.id)
}
}}
disabled={disabled}
>
<ThumbsDown className="mr-2 h-5 w-5" />
{falseLabel}
</Button>
</div>
</div>
)}
/>
)
}
// Progress indicator component // Progress indicator component
function ProgressIndicator({ function ProgressIndicator({
percentage, percentage,

View File

@@ -737,7 +737,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
if (type === 'section_header') return null if (type === 'section_header') return null
if (type === 'boolean') { if (type === 'boolean' || type === 'advance') {
return ( return (
<div <div
key={key} key={key}