2026-04-21 16:41:14 +02:00
|
|
|
'use client'
|
|
|
|
|
|
|
|
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
|
|
|
import { Slider } from '@/components/ui/slider'
|
|
|
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
|
|
|
import { Label } from '@/components/ui/label'
|
|
|
|
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
|
|
|
|
import { cn } from '@/lib/utils'
|
|
|
|
|
import { ThumbsUp, ThumbsDown, CheckCircle2 } from 'lucide-react'
|
|
|
|
|
|
|
|
|
|
export type EvaluationCriterion = {
|
|
|
|
|
id: string
|
|
|
|
|
label: string
|
|
|
|
|
description?: string | null
|
|
|
|
|
type: 'numeric' | 'text' | 'boolean' | 'advance' | 'section_header'
|
|
|
|
|
weight?: number | null
|
|
|
|
|
minScore: number
|
|
|
|
|
maxScore: number
|
|
|
|
|
required: boolean
|
|
|
|
|
trueLabel: string
|
|
|
|
|
falseLabel: string
|
|
|
|
|
maxLength: number
|
|
|
|
|
placeholder: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type ScoringMode = 'criteria' | 'global' | 'binary'
|
|
|
|
|
|
|
|
|
|
export type EvaluationFormFieldsProps = {
|
|
|
|
|
criteria: EvaluationCriterion[]
|
|
|
|
|
scoringMode: ScoringMode
|
|
|
|
|
requireFeedback: boolean
|
|
|
|
|
feedbackMinLength: number
|
|
|
|
|
criteriaValues: Record<string, number | boolean | string>
|
|
|
|
|
globalScore: string
|
|
|
|
|
binaryDecision: '' | 'accept' | 'reject'
|
|
|
|
|
feedbackText: string
|
|
|
|
|
isReadOnly: boolean
|
|
|
|
|
lastSavedAt?: Date | null
|
|
|
|
|
headerDescription?: string
|
|
|
|
|
onCriterionChange: (key: string, value: number | boolean | string) => void
|
|
|
|
|
onGlobalScoreChange: (value: string) => void
|
|
|
|
|
onBinaryChange: (value: 'accept' | 'reject') => void
|
|
|
|
|
onFeedbackChange: (value: string) => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Parse an EvaluationForm.criteriaJson payload into typed criteria for display.
|
|
|
|
|
* Kept in this file so every consumer normalizes in the same way.
|
|
|
|
|
*/
|
|
|
|
|
export function parseCriteriaFromForm(
|
|
|
|
|
criteriaJson: ReadonlyArray<Record<string, unknown>> | null | undefined,
|
|
|
|
|
): EvaluationCriterion[] {
|
|
|
|
|
if (!criteriaJson) return []
|
|
|
|
|
return criteriaJson.map((raw) => {
|
|
|
|
|
const c = raw as Record<string, unknown>
|
|
|
|
|
const type = (c.type as EvaluationCriterion['type']) || 'numeric'
|
|
|
|
|
let minScore = 1
|
|
|
|
|
let maxScore = 10
|
|
|
|
|
if (type === 'numeric' && typeof c.scale === 'string') {
|
|
|
|
|
const parts = c.scale.split('-').map(Number)
|
|
|
|
|
if (parts.length === 2 && !Number.isNaN(parts[0]) && !Number.isNaN(parts[1])) {
|
|
|
|
|
minScore = parts[0]
|
|
|
|
|
maxScore = parts[1]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
id: c.id as string,
|
|
|
|
|
label: c.label as string,
|
|
|
|
|
description: (c.description as string | null | undefined) ?? null,
|
|
|
|
|
type,
|
|
|
|
|
weight: (c.weight as number | null | undefined) ?? null,
|
|
|
|
|
minScore,
|
|
|
|
|
maxScore,
|
|
|
|
|
required: (c.required as boolean | undefined) ?? true,
|
|
|
|
|
trueLabel: (c.trueLabel as string | undefined) || 'Yes',
|
|
|
|
|
falseLabel: (c.falseLabel as string | undefined) || 'No',
|
|
|
|
|
maxLength: (c.maxLength as number | undefined) || 1000,
|
|
|
|
|
placeholder: (c.placeholder as string | undefined) || '',
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function EvaluationFormFields({
|
|
|
|
|
criteria,
|
|
|
|
|
scoringMode,
|
|
|
|
|
requireFeedback,
|
|
|
|
|
feedbackMinLength,
|
|
|
|
|
criteriaValues,
|
|
|
|
|
globalScore,
|
|
|
|
|
binaryDecision,
|
|
|
|
|
feedbackText,
|
|
|
|
|
isReadOnly,
|
|
|
|
|
lastSavedAt,
|
|
|
|
|
headerDescription,
|
|
|
|
|
onCriterionChange,
|
|
|
|
|
onGlobalScoreChange,
|
|
|
|
|
onBinaryChange,
|
|
|
|
|
onFeedbackChange,
|
|
|
|
|
}: EvaluationFormFieldsProps) {
|
|
|
|
|
const description =
|
|
|
|
|
headerDescription ??
|
|
|
|
|
(scoringMode === 'criteria'
|
|
|
|
|
? 'Complete all required fields below'
|
|
|
|
|
: `Provide your assessment using the ${scoringMode} scoring method`)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<div className="flex items-start justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<CardTitle>Evaluation Form</CardTitle>
|
|
|
|
|
<CardDescription>{description}</CardDescription>
|
|
|
|
|
</div>
|
|
|
|
|
{lastSavedAt && (
|
|
|
|
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
|
|
|
|
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
|
|
|
|
Saved {lastSavedAt.toLocaleTimeString()}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-6">
|
|
|
|
|
{scoringMode === 'criteria' && criteria.length > 0 && (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{criteria.map((criterion) => {
|
|
|
|
|
if (criterion.type === 'section_header') {
|
|
|
|
|
return (
|
|
|
|
|
<div key={criterion.id} className="border-b pb-2 pt-4 first:pt-0">
|
|
|
|
|
<h3 className="font-semibold text-lg">{criterion.label}</h3>
|
|
|
|
|
{criterion.description && (
|
|
|
|
|
<p className="text-sm text-muted-foreground mt-1">{criterion.description}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
disabled={isReadOnly}
|
|
|
|
|
onClick={() => onCriterionChange(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',
|
|
|
|
|
isReadOnly && 'opacity-60 cursor-default',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<ThumbsUp className="mr-2 h-5 w-5" />
|
|
|
|
|
{criterion.trueLabel || 'Yes'}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={isReadOnly}
|
|
|
|
|
onClick={() => onCriterionChange(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',
|
|
|
|
|
isReadOnly && 'opacity-60 cursor-default',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<ThumbsDown className="mr-2 h-5 w-5" />
|
|
|
|
|
{criterion.falseLabel || 'No'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (criterion.type === 'boolean') {
|
|
|
|
|
const currentValue = criteriaValues[criterion.id]
|
|
|
|
|
return (
|
|
|
|
|
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label className="text-base font-medium">
|
|
|
|
|
{criterion.label}
|
|
|
|
|
{criterion.required && <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-3">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={isReadOnly}
|
|
|
|
|
onClick={() => onCriterionChange(criterion.id, true)}
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
|
|
|
|
currentValue === true
|
2026-05-22 18:45:42 +02:00
|
|
|
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
2026-04-21 16:41:14 +02:00
|
|
|
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
|
|
|
|
|
isReadOnly && 'opacity-60 cursor-default',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<ThumbsUp className="mr-2 h-4 w-4" />
|
|
|
|
|
{criterion.trueLabel}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={isReadOnly}
|
|
|
|
|
onClick={() => onCriterionChange(criterion.id, false)}
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
|
|
|
|
currentValue === false
|
2026-05-22 18:45:42 +02:00
|
|
|
? 'border-red-500 bg-red-50 text-red-700'
|
2026-04-21 16:41:14 +02:00
|
|
|
: 'border-border hover:border-red-300 hover:bg-red-50/50',
|
|
|
|
|
isReadOnly && 'opacity-60 cursor-default',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<ThumbsDown className="mr-2 h-4 w-4" />
|
|
|
|
|
{criterion.falseLabel}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (criterion.type === 'text') {
|
|
|
|
|
const currentValue = (criteriaValues[criterion.id] as string) || ''
|
|
|
|
|
return (
|
|
|
|
|
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label className="text-base font-medium">
|
|
|
|
|
{criterion.label}
|
|
|
|
|
{criterion.required && <span className="text-destructive ml-1">*</span>}
|
|
|
|
|
</Label>
|
|
|
|
|
{criterion.description && (
|
|
|
|
|
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<Textarea
|
|
|
|
|
value={currentValue}
|
|
|
|
|
onChange={(e) => onCriterionChange(criterion.id, e.target.value)}
|
|
|
|
|
placeholder={criterion.placeholder || 'Enter your response...'}
|
|
|
|
|
rows={4}
|
|
|
|
|
maxLength={criterion.maxLength}
|
|
|
|
|
disabled={isReadOnly}
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-xs text-muted-foreground text-right">
|
|
|
|
|
{currentValue.length}/{criterion.maxLength}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Default: numeric criterion
|
|
|
|
|
const min = criterion.minScore ?? 1
|
|
|
|
|
const max = criterion.maxScore ?? 10
|
|
|
|
|
const currentValue = criteriaValues[criterion.id]
|
|
|
|
|
const displayValue = typeof currentValue === 'number' ? currentValue : undefined
|
|
|
|
|
const sliderValue =
|
|
|
|
|
typeof currentValue === 'number' ? currentValue : Math.ceil((min + max) / 2)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
|
|
|
|
|
<div className="flex items-start justify-between gap-4">
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label className="text-base font-medium">
|
|
|
|
|
{criterion.label}
|
|
|
|
|
{criterion.required && <span className="text-destructive ml-1">*</span>}
|
|
|
|
|
</Label>
|
|
|
|
|
{criterion.description && (
|
|
|
|
|
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<span className="shrink-0 rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
|
|
|
|
|
{displayValue !== undefined ? displayValue : '—'}/{max}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-xs text-muted-foreground w-4">{min}</span>
|
|
|
|
|
<Slider
|
|
|
|
|
min={min}
|
|
|
|
|
max={max}
|
|
|
|
|
step={1}
|
|
|
|
|
value={[sliderValue]}
|
|
|
|
|
onValueChange={(v) => onCriterionChange(criterion.id, v[0])}
|
|
|
|
|
className="flex-1"
|
|
|
|
|
disabled={isReadOnly}
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-xs text-muted-foreground w-4">{max}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-1 flex-wrap">
|
|
|
|
|
{Array.from({ length: max - min + 1 }, (_, i) => i + min).map((num) => (
|
|
|
|
|
<button
|
|
|
|
|
key={num}
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={isReadOnly}
|
|
|
|
|
onClick={() => onCriterionChange(criterion.id, num)}
|
|
|
|
|
className={cn(
|
|
|
|
|
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
|
|
|
|
displayValue !== undefined && displayValue === num
|
|
|
|
|
? 'bg-primary text-primary-foreground'
|
|
|
|
|
: displayValue !== undefined && displayValue > num
|
|
|
|
|
? 'bg-primary/20 text-primary'
|
|
|
|
|
: 'bg-muted hover:bg-muted/80',
|
|
|
|
|
isReadOnly && 'cursor-default',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{num}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{scoringMode === 'global' && (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<Label>
|
|
|
|
|
Overall Score <span className="text-destructive">*</span>
|
|
|
|
|
</Label>
|
|
|
|
|
<span className="rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
|
|
|
|
|
{globalScore || '—'}/10
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-xs text-muted-foreground">1</span>
|
|
|
|
|
<Slider
|
|
|
|
|
min={1}
|
|
|
|
|
max={10}
|
|
|
|
|
step={1}
|
|
|
|
|
value={[globalScore ? parseInt(globalScore, 10) : 5]}
|
|
|
|
|
onValueChange={(v) => onGlobalScoreChange(v[0].toString())}
|
|
|
|
|
className="flex-1"
|
|
|
|
|
disabled={isReadOnly}
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-xs text-muted-foreground">10</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-1 flex-wrap">
|
|
|
|
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => {
|
|
|
|
|
const current = globalScore ? parseInt(globalScore, 10) : 0
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={num}
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={isReadOnly}
|
|
|
|
|
onClick={() => onGlobalScoreChange(num.toString())}
|
|
|
|
|
className={cn(
|
|
|
|
|
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
|
|
|
|
current === num
|
|
|
|
|
? 'bg-primary text-primary-foreground'
|
|
|
|
|
: current > num
|
|
|
|
|
? 'bg-primary/20 text-primary'
|
|
|
|
|
: 'bg-muted hover:bg-muted/80',
|
|
|
|
|
isReadOnly && 'cursor-default',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{num}
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{scoringMode === 'binary' && (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>
|
|
|
|
|
Decision <span className="text-destructive">*</span>
|
|
|
|
|
</Label>
|
|
|
|
|
<RadioGroup
|
|
|
|
|
value={binaryDecision}
|
|
|
|
|
onValueChange={(v) => onBinaryChange(v as 'accept' | 'reject')}
|
|
|
|
|
disabled={isReadOnly}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
|
|
|
|
|
<RadioGroupItem value="accept" id="accept" />
|
|
|
|
|
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
|
|
|
|
|
<ThumbsUp className="h-4 w-4 text-emerald-600" />
|
|
|
|
|
<span>Accept — This project should advance</span>
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-red-50/50">
|
|
|
|
|
<RadioGroupItem value="reject" id="reject" />
|
|
|
|
|
<Label htmlFor="reject" className="flex items-center gap-2 cursor-pointer flex-1">
|
|
|
|
|
<ThumbsDown className="h-4 w-4 text-red-600" />
|
|
|
|
|
<span>Reject — This project should not advance</span>
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
</RadioGroup>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="feedbackText">
|
|
|
|
|
General Comment / Feedback
|
|
|
|
|
{requireFeedback && <span className="text-destructive ml-1">*</span>}
|
|
|
|
|
</Label>
|
|
|
|
|
<Textarea
|
|
|
|
|
id="feedbackText"
|
|
|
|
|
value={feedbackText}
|
|
|
|
|
onChange={(e) => onFeedbackChange(e.target.value)}
|
|
|
|
|
placeholder="Provide your feedback on the project..."
|
|
|
|
|
rows={8}
|
|
|
|
|
disabled={isReadOnly}
|
|
|
|
|
/>
|
|
|
|
|
{requireFeedback && (
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Minimum {feedbackMinLength} characters ({feedbackText.length}/{feedbackMinLength})
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)
|
|
|
|
|
}
|