diff --git a/src/app/(admin)/admin/dashboard-content.tsx b/src/app/(admin)/admin/dashboard-content.tsx index 181c202..6d6adad 100644 --- a/src/app/(admin)/admin/dashboard-content.tsx +++ b/src/app/(admin)/admin/dashboard-content.tsx @@ -22,6 +22,7 @@ import { ProjectListCompact } from '@/components/dashboard/project-list-compact' import { ActivityFeed } from '@/components/dashboard/activity-feed' import { CategoryBreakdown } from '@/components/dashboard/category-breakdown' import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton' +import { RecentEvaluations } from '@/components/dashboard/recent-evaluations' type DashboardContentProps = { editionId: string @@ -33,6 +34,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro { editionId }, { enabled: !!editionId, retry: 1, refetchInterval: 30_000 } ) + const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery( + { editionId, limit: 8 }, + { enabled: !!editionId, refetchInterval: 30_000 } + ) if (isLoading) { return @@ -158,15 +163,21 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro + + {recentEvals && recentEvals.length > 0 && ( + + + + )} {/* Right Column */}
- + - +
@@ -175,12 +186,12 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro {/* Bottom Full Width */}
- +
- + (null) + if (isLoading) { return } @@ -728,11 +741,20 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { Status Score Decision + {assignments.map((assignment) => ( - + { + if (assignment.evaluation?.status === 'SUBMITTED') { + setSelectedEvalAssignment(assignment) + } + }} + >
- )} + + {assignment.evaluation?.status === 'SUBMITTED' && ( + + )} + ))} @@ -815,6 +842,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { )} + {/* Evaluation Detail Sheet */} + { if (!open) setSelectedEvalAssignment(null) }} + /> + {/* AI Evaluation Summary */} {assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && ( void +}) { + if (!assignment?.evaluation) return null + + const ev = assignment.evaluation + const criterionScores = (ev.criterionScoresJson || {}) as Record + const hasScores = Object.keys(criterionScores).length > 0 + + // Try to get the evaluation form for labels + const roundId = assignment.roundId as string | undefined + const { data: activeForm } = trpc.evaluation.getStageForm.useQuery( + { roundId: roundId ?? '' }, + { enabled: !!roundId } + ) + + // Build label lookup from form criteria + const criteriaMap = new Map() + if (activeForm?.criteriaJson) { + for (const c of activeForm.criteriaJson as Array<{ id: string; label: string; type?: string; trueLabel?: string; falseLabel?: string }>) { + criteriaMap.set(c.id, { + label: c.label, + type: c.type || 'numeric', + trueLabel: c.trueLabel, + falseLabel: c.falseLabel, + }) + } + } + + return ( + + + + + + {assignment.user.name || assignment.user.email} + + + {ev.submittedAt + ? `Submitted ${formatDate(ev.submittedAt)}` + : 'Evaluation details'} + + + +
+ {/* Global stats */} +
+
+

Score

+

+ {ev.globalScore !== null ? `${ev.globalScore}/10` : '-'} +

+
+
+

Decision

+
+ {ev.binaryDecision !== null ? ( + ev.binaryDecision ? ( +
+ + Yes +
+ ) : ( +
+ + No +
+ ) + ) : ( + - + )} +
+
+
+ + {/* Criterion Scores */} + {hasScores && ( +
+

+ + Criterion Scores +

+
+ {Object.entries(criterionScores).map(([key, value]) => { + const meta = criteriaMap.get(key) + const label = meta?.label || key + const type = meta?.type || (typeof value === 'boolean' ? 'boolean' : typeof value === 'string' ? 'text' : 'numeric') + + if (type === 'section_header') return null + + if (type === 'boolean') { + return ( +
+ {label} + {value === true ? ( + + + {meta?.trueLabel || 'Yes'} + + ) : ( + + + {meta?.falseLabel || 'No'} + + )} +
+ ) + } + + if (type === 'text') { + return ( +
+ {label} +
+ {typeof value === 'string' ? value : String(value)} +
+
+ ) + } + + // Numeric + return ( +
+ {label} +
+
+
+
+ + {typeof value === 'number' ? value : '-'} + +
+
+ ) + })} +
+
+ )} + + {/* Feedback Text */} + {ev.feedbackText && ( +
+

+ + Feedback +

+
+ {ev.feedbackText} +
+
+ )} +
+ + + ) +} + export default function ProjectDetailPage({ params }: PageProps) { const { id } = use(params) diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index 0eaf885..f196e6f 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -108,6 +108,8 @@ import { AnimatedCard } from '@/components/shared/animated-container' import { DateTimePicker } from '@/components/ui/datetime-picker' import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog' import { motion } from 'motion/react' +import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder' +import type { Criterion } from '@/components/forms/evaluation-form-builder' // ── Status & type config maps ────────────────────────────────────────────── const roundStatusConfig = { @@ -3118,12 +3120,9 @@ function AIRecommendationsDisplay({ // ── Evaluation Criteria Editor ─────────────────────────────────────────── function EvaluationCriteriaEditor({ roundId }: { roundId: string }) { - const [editing, setEditing] = useState(false) - const [criteria, setCriteria] = useState>([]) - + const [pendingCriteria, setPendingCriteria] = useState(null) const utils = trpc.useUtils() + const { data: form, isLoading } = trpc.evaluation.getForm.useQuery( { roundId }, { refetchInterval: 30_000 }, @@ -3133,49 +3132,59 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) { onSuccess: () => { utils.evaluation.getForm.invalidate({ roundId }) toast.success('Evaluation criteria saved') - setEditing(false) + setPendingCriteria(null) }, onError: (err) => toast.error(err.message), }) - // Sync from server - if (form && !editing) { - const serverCriteria = form.criteriaJson ?? [] - if (JSON.stringify(serverCriteria) !== JSON.stringify(criteria)) { - setCriteria(serverCriteria) - } - } + // Convert server criteriaJson to Criterion[] format + const serverCriteria: Criterion[] = useMemo(() => { + if (!form?.criteriaJson) return [] + return (form.criteriaJson as Criterion[]).map((c) => { + // Handle legacy numeric-only format: convert "scale" string like "1-10" back to minScore/maxScore + const type = c.type || 'numeric' + if (type === 'numeric' && typeof c.scale === 'string') { + const parts = (c.scale as string).split('-').map(Number) + if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) { + return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion + } + } + return { ...c, type } as Criterion + }) + }, [form?.criteriaJson]) - const handleAdd = () => { - setCriteria([...criteria, { - id: `c-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, - label: '', - description: '', - weight: 1, - minScore: 0, - maxScore: 10, - }]) - setEditing(true) - } - - const handleRemove = (id: string) => { - setCriteria(criteria.filter((c) => c.id !== id)) - } - - const handleChange = (id: string, field: string, value: string | number) => { - setCriteria(criteria.map((c) => - c.id === id ? { ...c, [field]: value } : c, - )) - setEditing(true) - } + const handleChange = useCallback((criteria: Criterion[]) => { + setPendingCriteria(criteria) + }, []) const handleSave = () => { + const criteria = pendingCriteria ?? serverCriteria const validCriteria = criteria.filter((c) => c.label.trim()) if (validCriteria.length === 0) { toast.error('Add at least one criterion') return } - upsertMutation.mutate({ roundId, criteria: validCriteria }) + // Map to upsertForm format + upsertMutation.mutate({ + roundId, + criteria: validCriteria.map((c) => ({ + id: c.id, + label: c.label, + description: c.description, + type: c.type || 'numeric', + weight: c.weight, + scale: typeof c.scale === 'number' ? c.scale : undefined, + minScore: (c as any).minScore, + maxScore: (c as any).maxScore, + required: c.required, + maxLength: c.maxLength, + placeholder: c.placeholder, + trueLabel: c.trueLabel, + falseLabel: c.falseLabel, + condition: c.condition, + sectionId: c.sectionId, + })), + }) } return ( @@ -3185,30 +3194,22 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
Evaluation Criteria - {form ? `Version ${form.version} \u2014 ${form.criteriaJson.length} criteria` : 'No criteria defined yet'} + {form + ? `Version ${form.version} \u2014 ${(form.criteriaJson as Criterion[]).filter((c) => (c.type || 'numeric') !== 'section_header').length} criteria` + : 'No criteria defined yet. Add numeric scores, yes/no questions, and text fields.'}
-
- {editing && ( - - )} - {editing ? ( - ) : ( - - )} -
+
+ )}
@@ -3216,83 +3217,11 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
{[1, 2, 3].map((i) => )}
- ) : criteria.length === 0 ? ( -
- -

No evaluation criteria defined

-

Add criteria that jurors will use to score projects

-
) : ( -
- {criteria.map((c, idx) => ( -
- - {idx + 1} - -
- handleChange(c.id, 'label', e.target.value)} - className="h-8 text-sm" - /> - handleChange(c.id, 'description', e.target.value)} - className="h-7 text-xs" - /> -
-
- - handleChange(c.id, 'weight', Number(e.target.value))} - className="h-7 text-xs" - /> -
-
- - handleChange(c.id, 'minScore', Number(e.target.value))} - className="h-7 text-xs" - /> -
-
- - handleChange(c.id, 'maxScore', Number(e.target.value))} - className="h-7 text-xs" - /> -
-
-
- -
- ))} - {!editing && ( - - )} -
+ )}
diff --git a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx index 18b761d..1927fb9 100644 --- a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx +++ b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { use, useState, useEffect } from 'react' +import { use, useState, useEffect, useRef, useCallback } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' import type { Route } from 'next' @@ -12,17 +12,9 @@ import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' -import { - AlertDialog, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' -import { Checkbox } from '@/components/ui/checkbox' import { cn } from '@/lib/utils' -import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock } from 'lucide-react' +import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer' +import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2 } from 'lucide-react' import { toast } from 'sonner' import type { EvaluationConfig } from '@/types/competition-configs' @@ -36,15 +28,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { const { roundId, projectId } = params const utils = trpc.useUtils() - const [showCOIDialog, setShowCOIDialog] = useState(true) - const [coiAccepted, setCoiAccepted] = useState(false) - - // Evaluation form state - const [criteriaScores, setCriteriaScores] = useState>({}) + // Evaluation form state — stores all criterion values (numeric, boolean, text) + const [criteriaValues, setCriteriaValues] = useState>({}) const [globalScore, setGlobalScore] = useState('') const [binaryDecision, setBinaryDecision] = useState<'accept' | 'reject' | ''>('') const [feedbackText, setFeedbackText] = useState('') + // Track dirty state for autosave + const isDirtyRef = useRef(false) + const evaluationIdRef = useRef(null) + const isSubmittedRef = useRef(false) + const autosaveTimerRef = useRef | null>(null) + const [lastSavedAt, setLastSavedAt] = useState(null) + // Fetch project const { data: project } = trpc.project.get.useQuery( { id: projectId }, @@ -71,7 +67,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { { enabled: !!myAssignment?.id } ) - // Fetch the active evaluation form for this round (independent of evaluation existence) + // Fetch the active evaluation form for this round const { data: activeForm } = trpc.evaluation.getStageForm.useQuery( { roundId }, { enabled: !!roundId } @@ -80,17 +76,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { // Start evaluation mutation (creates draft) const startMutation = trpc.evaluation.start.useMutation() - // Autosave mutation + // Autosave mutation (silent) const autosaveMutation = trpc.evaluation.autosave.useMutation({ onSuccess: () => { - toast.success('Draft saved', { duration: 1500 }) + isDirtyRef.current = false + setLastSavedAt(new Date()) }, - onError: (err) => toast.error(err.message), }) // Submit mutation const submitMutation = trpc.evaluation.submit.useMutation({ onSuccess: () => { + isSubmittedRef.current = true + isDirtyRef.current = false utils.roundAssignment.getMyAssignments.invalidate() utils.evaluation.get.invalidate() toast.success('Evaluation submitted successfully') @@ -99,15 +97,24 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { onError: (err) => toast.error(err.message), }) + // Track evaluation ID + useEffect(() => { + if (existingEvaluation?.id) { + evaluationIdRef.current = existingEvaluation.id + } + }, [existingEvaluation?.id]) + // Load existing evaluation data useEffect(() => { if (existingEvaluation) { if (existingEvaluation.criterionScoresJson) { - const scores: Record = {} + const values: Record = {} Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => { - scores[key] = typeof value === 'number' ? value : 0 + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') { + values[key] = value + } }) - setCriteriaScores(scores) + setCriteriaValues(values) } if (existingEvaluation.globalScore) { setGlobalScore(existingEvaluation.globalScore.toString()) @@ -118,6 +125,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { if (existingEvaluation.feedbackText) { setFeedbackText(existingEvaluation.feedbackText) } + isDirtyRef.current = false } }, [existingEvaluation]) @@ -127,12 +135,12 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { const requireFeedback = evalConfig?.requireFeedback ?? true const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10 - // Get criteria from the active evaluation form (independent of evaluation record) + // Parse criteria from the active form const criteria = (activeForm?.criteriaJson ?? []).map((c) => { - // Parse scale string like "1-10" into minScore/maxScore + const type = (c as any).type || 'numeric' let minScore = 1 let maxScore = 10 - if (c.scale) { + if (type === 'numeric' && c.scale) { const parts = c.scale.split('-').map(Number) if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) { minScore = parts[0] @@ -143,33 +151,135 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { id: c.id, label: c.label, description: c.description, + type: type as 'numeric' | 'text' | 'boolean' | 'section_header', weight: c.weight, minScore, maxScore, + required: (c as any).required ?? true, + trueLabel: (c as any).trueLabel || 'Yes', + falseLabel: (c as any).falseLabel || 'No', + maxLength: (c as any).maxLength || 1000, + placeholder: (c as any).placeholder || '', } }) + // Build current form data for autosave + const buildSavePayload = useCallback(() => { + return { + criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : undefined, + globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null, + binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null, + feedbackText: feedbackText || null, + } + }, [scoringMode, criteriaValues, globalScore, binaryDecision, feedbackText]) + + // Perform autosave + const performAutosave = useCallback(async () => { + if (!isDirtyRef.current || isSubmittedRef.current) return + if (existingEvaluation?.status === 'SUBMITTED') return + + let evalId = evaluationIdRef.current + if (!evalId && myAssignment) { + try { + const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id }) + evalId = newEval.id + evaluationIdRef.current = evalId + } catch { + return + } + } + if (!evalId) return + + autosaveMutation.mutate({ id: evalId, ...buildSavePayload() }) + }, [myAssignment, existingEvaluation?.status, startMutation, autosaveMutation, buildSavePayload]) + + // Debounced autosave: save 3 seconds after last change + useEffect(() => { + if (!isDirtyRef.current) return + + if (autosaveTimerRef.current) { + clearTimeout(autosaveTimerRef.current) + } + + autosaveTimerRef.current = setTimeout(() => { + performAutosave() + }, 3000) + + return () => { + if (autosaveTimerRef.current) { + clearTimeout(autosaveTimerRef.current) + } + } + }, [criteriaValues, globalScore, binaryDecision, feedbackText, performAutosave]) + + // Save on page leave (beforeunload) + useEffect(() => { + const handleBeforeUnload = () => { + if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) { + const payload = JSON.stringify({ + id: evaluationIdRef.current, + ...buildSavePayload(), + }) + navigator.sendBeacon?.('/api/trpc/evaluation.autosave', payload) + } + } + + window.addEventListener('beforeunload', handleBeforeUnload) + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, [buildSavePayload]) + + // Save on component unmount (navigating away within the app) + useEffect(() => { + return () => { + if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) { + performAutosave() + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Mark dirty when form values change + const handleCriterionChange = (key: string, value: number | boolean | string) => { + setCriteriaValues((prev) => ({ ...prev, [key]: value })) + isDirtyRef.current = true + } + + const handleGlobalScoreChange = (value: string) => { + setGlobalScore(value) + isDirtyRef.current = true + } + + const handleBinaryChange = (value: 'accept' | 'reject') => { + setBinaryDecision(value) + isDirtyRef.current = true + } + + const handleFeedbackChange = (value: string) => { + setFeedbackText(value) + isDirtyRef.current = true + } + const handleSaveDraft = async () => { if (!myAssignment) { toast.error('Assignment not found') return } - // Create evaluation if it doesn't exist - let evaluationId = existingEvaluation?.id + let evaluationId = evaluationIdRef.current if (!evaluationId) { const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id }) evaluationId = newEval.id + evaluationIdRef.current = evaluationId } - // Autosave current state - autosaveMutation.mutate({ - id: evaluationId, - criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : undefined, - globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null, - binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null, - feedbackText: feedbackText || null, - }) + autosaveMutation.mutate( + { id: evaluationId, ...buildSavePayload() }, + { onSuccess: () => { + isDirtyRef.current = false + setLastSavedAt(new Date()) + toast.success('Draft saved', { duration: 1500 }) + }} + ) } const handleSubmit = async () => { @@ -178,17 +288,23 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { return } - // Validation based on scoring mode + // Validation for criteria mode if (scoringMode === 'criteria') { - if (!criteria || criteria.length === 0) { - toast.error('No criteria found for this evaluation') - return - } - const requiredCriteria = evalConfig?.requireAllCriteriaScored !== false - if (requiredCriteria) { - const allScored = criteria.every((c) => criteriaScores[c.id] !== undefined) - if (!allScored) { - toast.error('Please score all criteria') + const requiredCriteria = criteria.filter((c) => + c.type !== 'section_header' && c.required + ) + for (const c of requiredCriteria) { + const val = criteriaValues[c.id] + if (c.type === 'numeric' && (val === undefined || val === null)) { + toast.error(`Please score "${c.label}"`) + return + } + if (c.type === 'boolean' && val === undefined) { + toast.error(`Please answer "${c.label}"`) + return + } + if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) { + toast.error(`Please fill in "${c.label}"`) return } } @@ -216,74 +332,43 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { } } - // Create evaluation if needed - let evaluationId = existingEvaluation?.id + let evaluationId = evaluationIdRef.current if (!evaluationId) { const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id }) evaluationId = newEval.id + evaluationIdRef.current = evaluationId + } + + // Compute a weighted global score from numeric criteria for the global score field + const numericCriteria = criteria.filter((c) => c.type === 'numeric') + let computedGlobalScore = 5 + if (scoringMode === 'criteria' && numericCriteria.length > 0) { + let totalWeight = 0 + let weightedSum = 0 + for (const c of numericCriteria) { + const val = criteriaValues[c.id] + if (typeof val === 'number') { + const w = c.weight ?? 1 + // Normalize to 1-10 scale + const normalized = ((val - c.minScore) / (c.maxScore - c.minScore)) * 9 + 1 + weightedSum += normalized * w + totalWeight += w + } + } + if (totalWeight > 0) { + computedGlobalScore = Math.round(weightedSum / totalWeight) + } } - // Submit submitMutation.mutate({ id: evaluationId, - criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : {}, - globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : 5, + criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : {}, + globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : computedGlobalScore, binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true, feedbackText: feedbackText || 'No feedback provided', }) } - // COI Dialog - if (!coiAccepted && showCOIDialog && evalConfig?.coiRequired !== false) { - return ( - - - - Conflict of Interest Declaration - -
-

- Before evaluating this project, you must confirm that you have no conflict of - interest. -

-

- A conflict of interest exists if you have a personal, professional, or financial - relationship with the project team that could influence your judgment. -

-
-
-
-
- setCoiAccepted(checked as boolean)} - /> - -
- - - - -
-
- ) - } - if (!round || !project) { return (
@@ -299,7 +384,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { ) } - // Check if round is active — round status is the primary gate for evaluations + // Check if round is active const isRoundActive = round.status === 'ROUND_ACTIVE' if (!isRoundActive) { @@ -352,6 +437,9 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
+ {/* Project Documents */} + + @@ -359,7 +447,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {

Important Reminder

Your evaluation will be used to assess this project. Please provide thoughtful and - constructive feedback to help the team improve. + constructive feedback. Your progress is automatically saved as a draft.

@@ -367,21 +455,116 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { - Evaluation Form - - Provide your assessment using the {scoringMode} scoring method - +
+
+ Evaluation Form + + {scoringMode === 'criteria' + ? 'Complete all required fields below' + : `Provide your assessment using the ${scoringMode} scoring method`} + +
+ {lastSavedAt && ( + + + Saved {lastSavedAt.toLocaleTimeString()} + + )} +
- {/* Criteria-based scoring */} + {/* Criteria-based scoring with mixed types */} {scoringMode === 'criteria' && criteria && criteria.length > 0 && (
-

Criteria Scores

{criteria.map((criterion) => { + if (criterion.type === 'section_header') { + return ( +
+

{criterion.label}

+ {criterion.description && ( +

{criterion.description}

+ )} +
+ ) + } + + if (criterion.type === 'boolean') { + const currentValue = criteriaValues[criterion.id] + return ( +
+
+ + {criterion.description && ( +

{criterion.description}

+ )} +
+
+ + +
+
+ ) + } + + if (criterion.type === 'text') { + const currentValue = (criteriaValues[criterion.id] as string) || '' + return ( +
+
+ + {criterion.description && ( +

{criterion.description}

+ )} +
+