'use client' import { useState, useEffect, useCallback, useTransition, useMemo } from 'react' import { useRouter } from 'next/navigation' import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { useDebouncedCallback } from 'use-debounce' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { Slider } from '@/components/ui/slider' import { Switch } from '@/components/ui/switch' import { Badge } from '@/components/ui/badge' import { Progress } from '@/components/ui/progress' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog' import { Loader2, Save, Send, CheckCircle2, AlertCircle, Clock, Star, ThumbsUp, ThumbsDown, } from 'lucide-react' import { cn } from '@/lib/utils' import { toast } from 'sonner' import { CountdownTimer } from '@/components/shared/countdown-timer' // Define criterion type from the evaluation form JSON type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header' interface CriterionCondition { criterionId: string operator: 'equals' | 'greaterThan' | 'lessThan' value: number | string | boolean } interface Criterion { id: string label: string description?: string type?: CriterionType // defaults to 'numeric' // Numeric scale?: number // max value (e.g., 5 or 10) weight?: number required?: boolean // Text maxLength?: number placeholder?: string // Boolean trueLabel?: string falseLabel?: string // Conditional visibility condition?: CriterionCondition // Section grouping sectionId?: string } interface EvaluationFormProps { assignmentId: string evaluationId: string | null projectTitle: string criteria: Criterion[] initialData?: { criterionScoresJson: Record | null globalScore: number | null binaryDecision: boolean | null feedbackText: string | null status: string } isVotingOpen: boolean deadline?: Date | null } const createEvaluationSchema = (criteria: Criterion[]) => { const criterionFields: Record = {} for (const c of criteria) { const type = c.type || 'numeric' if (type === 'section_header') continue if (type === 'numeric') { criterionFields[c.id] = z.number() } else if (type === 'text') { criterionFields[c.id] = z.string() } else if (type === 'boolean') { criterionFields[c.id] = z.boolean() } } return z.object({ criterionScores: z.object(criterionFields).passthrough(), globalScore: z.number().int().min(1).max(10), binaryDecision: z.boolean(), feedbackText: z.string().min(10, 'Please provide at least 10 characters of feedback'), }) } type EvaluationFormData = { criterionScores: Record globalScore: number binaryDecision: boolean feedbackText: string } /** Evaluate whether a condition is met based on current form values */ function evaluateCondition( condition: CriterionCondition, scores: Record ): boolean { const val = scores[condition.criterionId] if (val === undefined) return false switch (condition.operator) { case 'equals': return val === condition.value case 'greaterThan': return typeof val === 'number' && typeof condition.value === 'number' && val > condition.value case 'lessThan': return typeof val === 'number' && typeof condition.value === 'number' && val < condition.value default: return true } } export function EvaluationForm({ assignmentId, evaluationId, projectTitle, criteria, initialData, isVotingOpen, deadline, }: EvaluationFormProps) { const router = useRouter() const [isPending, startTransition] = useTransition() const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [lastSaved, setLastSaved] = useState(null) // Progress tracking state const [touchedCriteria, setTouchedCriteria] = useState>(() => { const initial = new Set() if (initialData?.criterionScoresJson) { for (const key of Object.keys(initialData.criterionScoresJson)) { initial.add(key) } } return initial }) const [globalScoreTouched, setGlobalScoreTouched] = useState( () => initialData?.globalScore != null ) const [decisionTouched, setDecisionTouched] = useState( () => initialData?.binaryDecision != null ) // Compute which criteria are scorable (not section headers) const scorableCriteria = useMemo( () => criteria.filter((c) => (c.type || 'numeric') !== 'section_header'), [criteria] ) // Initialize criterion scores with existing data or defaults const defaultCriterionScores: Record = {} scorableCriteria.forEach((c) => { const type = c.type || 'numeric' const existing = initialData?.criterionScoresJson?.[c.id] if (type === 'numeric') { defaultCriterionScores[c.id] = typeof existing === 'number' ? existing : Math.ceil((c.scale ?? 5) / 2) } else if (type === 'text') { defaultCriterionScores[c.id] = typeof existing === 'string' ? existing : '' } else if (type === 'boolean') { defaultCriterionScores[c.id] = typeof existing === 'boolean' ? existing : false } }) const form = useForm({ resolver: zodResolver(createEvaluationSchema(criteria)), defaultValues: { criterionScores: defaultCriterionScores, globalScore: initialData?.globalScore ?? 5, binaryDecision: initialData?.binaryDecision ?? false, feedbackText: initialData?.feedbackText ?? '', }, mode: 'onChange', }) const { watch, handleSubmit, control, formState } = form const { errors, isValid, isDirty } = formState // Progress tracking callbacks const onCriterionTouch = useCallback((criterionId: string) => { setTouchedCriteria((prev) => { if (prev.has(criterionId)) return prev const next = new Set(prev) next.add(criterionId) return next }) }, []) // Compute progress - section_headers count as always complete (skip them) const feedbackValue = watch('feedbackText') const watchedScores = watch('criterionScores') const progress = useMemo(() => { // Only count scorable criteria (not section headers) const totalItems = scorableCriteria.length + 3 // Count completed criteria let criteriaDone = 0 for (const c of scorableCriteria) { const type = c.type || 'numeric' if (type === 'numeric') { if (touchedCriteria.has(c.id)) criteriaDone++ } else if (type === 'text') { const val = watchedScores?.[c.id] if (typeof val === 'string' && val.length > 0) criteriaDone++ } else if (type === 'boolean') { if (touchedCriteria.has(c.id)) criteriaDone++ } } const completedItems = criteriaDone + (globalScoreTouched ? 1 : 0) + (decisionTouched ? 1 : 0) + (feedbackValue.length >= 10 ? 1 : 0) const percentage = Math.round((completedItems / totalItems) * 100) return { totalItems, completedItems, percentage, criteriaDone, criteriaTotal: scorableCriteria.length } }, [scorableCriteria, touchedCriteria, watchedScores, globalScoreTouched, decisionTouched, feedbackValue]) // tRPC mutations const startEvaluation = trpc.evaluation.start.useMutation() const autosave = trpc.evaluation.autosave.useMutation() const submit = trpc.evaluation.submit.useMutation() const utils = trpc.useUtils() // State to track the current evaluation ID (might be created on first autosave) const [currentEvaluationId, setCurrentEvaluationId] = useState(evaluationId) // Create evaluation if it doesn't exist useEffect(() => { if (!currentEvaluationId && isVotingOpen) { startEvaluation.mutate( { assignmentId }, { onSuccess: (data) => { setCurrentEvaluationId(data.id) }, } ) } }, [assignmentId, currentEvaluationId, isVotingOpen]) // Debounced autosave function const debouncedAutosave = useDebouncedCallback( async (data: EvaluationFormData) => { if (!currentEvaluationId || !isVotingOpen) return setAutosaveStatus('saving') try { await autosave.mutateAsync({ id: currentEvaluationId, criterionScoresJson: data.criterionScores, globalScore: data.globalScore, binaryDecision: data.binaryDecision, feedbackText: data.feedbackText, }) setAutosaveStatus('saved') setLastSaved(new Date()) // Reset to idle after a few seconds setTimeout(() => setAutosaveStatus('idle'), 3000) } catch (error) { console.error('Autosave failed:', error) setAutosaveStatus('error') } }, 3000 // 3 second debounce ) // Watch form values and trigger autosave const watchedValues = watch() useEffect(() => { if (isDirty && isVotingOpen) { debouncedAutosave(watchedValues) } }, [watchedValues, isDirty, isVotingOpen, debouncedAutosave]) // Submit handler const onSubmit = async (data: EvaluationFormData) => { if (!currentEvaluationId) return try { await submit.mutateAsync({ id: currentEvaluationId, criterionScoresJson: data.criterionScores, globalScore: data.globalScore, binaryDecision: data.binaryDecision, feedbackText: data.feedbackText, }) // Invalidate queries and redirect utils.assignment.myAssignments.invalidate() toast.success('Evaluation submitted successfully!') startTransition(() => { router.push('/jury/assignments') router.refresh() }) } catch (error) { console.error('Submit failed:', error) } } const isSubmitted = initialData?.status === 'SUBMITTED' || initialData?.status === 'LOCKED' const isReadOnly = isSubmitted || !isVotingOpen return (
{/* Status bar */}

{projectTitle}

{!isReadOnly && deadline && ( )} {!isReadOnly && ( )}
{!isReadOnly && (
Submit Evaluation? Once submitted, you cannot edit your evaluation. Please review your scores and feedback before confirming. Cancel {submit.isPending ? ( ) : null} Confirm Submit
)} {isReadOnly && ( {isSubmitted ? 'Submitted' : 'Read Only'} )}
{/* Criteria scoring */} {criteria.length > 0 && ( Evaluation Criteria Rate the project on each criterion below {criteria.map((criterion) => { const type = criterion.type || 'numeric' // Evaluate conditional visibility if (criterion.condition) { const conditionMet = evaluateCondition(criterion.condition, watchedScores ?? {}) if (!conditionMet) return null } // Section header if (type === 'section_header') { return } // Numeric (default) if (type === 'numeric') { return ( ) } // Text if (type === 'text') { return ( ) } // Boolean if (type === 'boolean') { return ( ) } return null })} )} {/* Global score */} Overall Score Rate the project overall on a scale of 1 to 10 (
Poor {field.value} Excellent
{ field.onChange(v[0]) setGlobalScoreTouched(true) }} disabled={isReadOnly} className="py-4" />
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => ( ))}
)} />
{/* Binary decision */} Recommendation Do you recommend this project to advance to the next round? (
)} />
{/* Feedback text */} Written Feedback Provide constructive feedback for this project (minimum 10 characters) (