diff --git a/src/app/(admin)/admin/rounds/[roundId]/jurors/[userId]/evaluate/[projectId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/jurors/[userId]/evaluate/[projectId]/page.tsx new file mode 100644 index 0000000..98482be --- /dev/null +++ b/src/app/(admin)/admin/rounds/[roundId]/jurors/[userId]/evaluate/[projectId]/page.tsx @@ -0,0 +1,543 @@ +'use client' + +import { use, useState, useEffect, useRef, useCallback } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import type { Route } from 'next' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { Badge } from '@/components/ui/badge' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer' +import { ArrowLeft, Save, Send, UserCheck, Lock } from 'lucide-react' +import { toast } from 'sonner' +import type { EvaluationConfig } from '@/types/competition-configs' +import { + EvaluationFormFields, + parseCriteriaFromForm, +} from '@/components/evaluation/evaluation-form-fields' + +type PageProps = { + params: Promise<{ roundId: string; userId: string; projectId: string }> +} + +export default function AdminProxyEvaluatePage({ params: paramsPromise }: PageProps) { + const { roundId, userId, projectId } = use(paramsPromise) + const router = useRouter() + const utils = trpc.useUtils() + const backHref = `/admin/rounds/${roundId}/jurors/${userId}/evaluate` as Route + + // Form state — mirrors the juror evaluate page + const [criteriaValues, setCriteriaValues] = useState>({}) + const [globalScore, setGlobalScore] = useState('') + const [binaryDecision, setBinaryDecision] = useState<'' | 'accept' | 'reject'>('') + const [feedbackText, setFeedbackText] = useState('') + + const isDirtyRef = useRef(false) + const evaluationIdRef = useRef(null) + const isSubmittedRef = useRef(false) + const isSubmittingRef = useRef(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const startPromiseRef = useRef | null>(null) + const autosaveTimerRef = useRef | null>(null) + const [lastSavedAt, setLastSavedAt] = useState(null) + const [confirmOpen, setConfirmOpen] = useState(false) + + // Juror + round + all assignments (we filter to the one matching projectId) + const { data: jurorData, isLoading: jurorLoading } = + trpc.evaluation.getJurorAssignmentsForRound.useQuery({ roundId, userId }) + + const assignment = jurorData?.assignments.find((a) => a.project.id === projectId) + const project = assignment?.project + const round = jurorData?.round + const juror = jurorData?.juror + + // Full round config (for scoringMode / requireFeedback etc.) + const { data: fullRound } = trpc.round.getById.useQuery( + { id: roundId }, + { enabled: !!roundId }, + ) + + // Existing evaluation for this assignment, if any (admin-readable via evaluation.get) + const { data: existingEvaluation } = trpc.evaluation.get.useQuery( + { assignmentId: assignment?.id ?? '' }, + { enabled: !!assignment?.id }, + ) + + // Active form for this category + const { data: activeForm } = trpc.evaluation.getStageForm.useQuery( + { roundId, category: project?.competitionCategory }, + { enabled: !!roundId && !!project }, + ) + + const startMutation = trpc.evaluation.adminStart.useMutation() + const autosaveMutation = trpc.evaluation.adminAutosave.useMutation({ + onSuccess: () => { + isDirtyRef.current = false + setLastSavedAt(new Date()) + }, + }) + const submitMutation = trpc.evaluation.adminSubmitOnBehalf.useMutation({ + onSuccess: () => { + isSubmittedRef.current = true + isDirtyRef.current = false + utils.evaluation.getJurorAssignmentsForRound.invalidate({ roundId, userId }) + utils.evaluation.get.invalidate() + utils.analytics.getJurorWorkload.invalidate({ roundId }) + toast.success(`Evaluation submitted on behalf of ${juror?.name || 'juror'}`) + router.push(backHref) + }, + onError: (err) => { + toast.error(err.message) + isSubmittingRef.current = false + setIsSubmitting(false) + }, + }) + + useEffect(() => { + if (existingEvaluation?.id) { + evaluationIdRef.current = existingEvaluation.id + } + }, [existingEvaluation?.id]) + + // Load existing evaluation values (draft or submitted) + useEffect(() => { + if (existingEvaluation) { + if (existingEvaluation.criterionScoresJson) { + const values: Record = {} + Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => { + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') { + values[key] = value + } + }) + setCriteriaValues(values) + } + if (existingEvaluation.globalScore != null) { + setGlobalScore(existingEvaluation.globalScore.toString()) + } + if (existingEvaluation.binaryDecision !== null) { + setBinaryDecision(existingEvaluation.binaryDecision ? 'accept' : 'reject') + } + if (existingEvaluation.feedbackText) { + setFeedbackText(existingEvaluation.feedbackText) + } + isDirtyRef.current = false + } + }, [existingEvaluation]) + + // Config + const evalConfig: EvaluationConfig | null = (fullRound?.configJson as EvaluationConfig | null) ?? null + const scoringMode = evalConfig?.scoringMode ?? 'criteria' + const requireFeedback = evalConfig?.requireFeedback ?? true + const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10 + + const criteria = parseCriteriaFromForm( + activeForm?.criteriaJson as ReadonlyArray> | null | undefined, + ) + + // Seed midpoint values for numeric criteria on first load + const criteriaInitializedRef = useRef(false) + useEffect(() => { + if (criteriaInitializedRef.current || criteria.length === 0) return + if (existingEvaluation?.criterionScoresJson) return + criteriaInitializedRef.current = true + + const defaults: Record = {} + for (const c of criteria) { + if (c.type === 'numeric') { + defaults[c.id] = Math.ceil((c.minScore + c.maxScore) / 2) + } + } + if (Object.keys(defaults).length > 0) { + setCriteriaValues((prev) => ({ ...defaults, ...prev })) + } + }, [criteria, existingEvaluation?.criterionScoresJson]) + + 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]) + + const performAutosave = useCallback(async () => { + if (!isDirtyRef.current || isSubmittedRef.current || isSubmittingRef.current) return + if (existingEvaluation?.status === 'SUBMITTED' || existingEvaluation?.status === 'LOCKED') return + + let evalId = evaluationIdRef.current + if (!evalId && assignment) { + try { + if (!startPromiseRef.current) { + startPromiseRef.current = startMutation.mutateAsync({ assignmentId: assignment.id }) + } + const newEval = await startPromiseRef.current + evalId = newEval.id + evaluationIdRef.current = evalId + } catch { + return + } finally { + startPromiseRef.current = null + } + } + if (!evalId) return + + autosaveMutation.mutate({ id: evalId, ...buildSavePayload() }) + }, [assignment, existingEvaluation?.status, startMutation, autosaveMutation, buildSavePayload]) + + // Debounced autosave + 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 unmount + useEffect(() => { + return () => { + if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) { + performAutosave() + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + 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 (!assignment) { + toast.error('Assignment not found') + return + } + let evaluationId = evaluationIdRef.current + if (!evaluationId) { + if (!startPromiseRef.current) { + startPromiseRef.current = startMutation.mutateAsync({ assignmentId: assignment.id }) + } + const newEval = await startPromiseRef.current + startPromiseRef.current = null + evaluationId = newEval.id + evaluationIdRef.current = evaluationId + } + autosaveMutation.mutate( + { id: evaluationId, ...buildSavePayload() }, + { + onSuccess: () => { + isDirtyRef.current = false + setLastSavedAt(new Date()) + toast.success('Draft saved', { duration: 1500 }) + }, + }, + ) + } + + const validateBeforeSubmit = (): string | null => { + if (scoringMode === '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)) return `Please score "${c.label}"` + if ((c.type === 'boolean' || c.type === 'advance') && val === undefined) return `Please answer "${c.label}"` + if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) return `Please fill in "${c.label}"` + } + } + if (scoringMode === 'global') { + const score = parseInt(globalScore, 10) + if (isNaN(score) || score < 1 || score > 10) return 'Please enter a valid score between 1 and 10' + } + if (scoringMode === 'binary') { + if (!binaryDecision) return 'Please select accept or reject' + } + if (requireFeedback) { + if (!feedbackText.trim() || feedbackText.length < feedbackMinLength) { + return `Please provide feedback (minimum ${feedbackMinLength} characters)` + } + } + return null + } + + const handleOpenConfirm = () => { + const error = validateBeforeSubmit() + if (error) { + toast.error(error) + return + } + setConfirmOpen(true) + } + + const handleConfirmedSubmit = async () => { + if (autosaveTimerRef.current) { + clearTimeout(autosaveTimerRef.current) + autosaveTimerRef.current = null + } + if (!assignment) { + toast.error('Assignment not found') + return + } + isSubmittingRef.current = true + setIsSubmitting(true) + + let evaluationId = evaluationIdRef.current + if (!evaluationId) { + if (!startPromiseRef.current) { + startPromiseRef.current = startMutation.mutateAsync({ assignmentId: assignment.id }) + } + const newEval = await startPromiseRef.current + startPromiseRef.current = null + evaluationId = newEval.id + evaluationIdRef.current = evaluationId + } + + // Compute global score from weighted criteria (same as juror page) + 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 + const normalized = ((val - c.minScore) / (c.maxScore - c.minScore)) * 9 + 1 + weightedSum += normalized * w + totalWeight += w + } + } + if (totalWeight > 0) { + computedGlobalScore = Math.round(weightedSum / totalWeight) + } + } + + submitMutation.mutate({ + id: evaluationId, + criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : {}, + globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : computedGlobalScore, + binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true, + feedbackText: feedbackText || 'No feedback provided', + }) + } + + if (jurorLoading || !round || !juror) { + return ( +
+ + +
+ ) + } + + if (!assignment || !project) { + return ( +
+ + + +

Assignment not found

+

+ This juror does not have an assignment for this project in the selected round. +

+
+
+
+ ) + } + + const isSubmittedEvaluation = existingEvaluation?.status === 'SUBMITTED' || existingEvaluation?.status === 'LOCKED' + const isReadOnly = isSubmittedEvaluation + const hasCOI = assignment.conflictOfInterest?.hasConflict === true + + return ( +
+
+ +
+

+ {isReadOnly ? 'Submitted Evaluation' : 'Fill In Evaluation'} +

+
+

{project.title}

+ {project.competitionCategory && ( + + {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} + + )} +
+
+
+ + + + +
+

+ Acting on behalf of {juror.name || juror.email} +

+

+ Window and round-active checks are bypassed for this submission. A proxy audit entry + is recorded with your admin ID and the juror's ID. +

+
+
+
+ + {isReadOnly && ( + + + +
+

View-Only

+

+ This evaluation has already been submitted. To change it, reset via the + round dashboard first. +

+
+
+
+ )} + + {hasCOI && !isReadOnly && ( + + + +
+

Conflict of interest declared

+

+ The juror declared a conflict on this project. Reassign via the COI review + workflow instead of submitting a proxy evaluation. +

+
+
+
+ )} + + + + + + {isReadOnly ? ( +
+ +
+ ) : ( +
+ +
+ + + + + + + + Submit on behalf of {juror.name || juror.email}? + + This will submit the evaluation as if it came from the juror. The current + voting window and round-active status will be bypassed. The audit log will + record both your admin ID and the juror's ID. This action cannot be + undone without resetting the evaluation first. + + + + Cancel + + {isSubmitting ? 'Submitting...' : 'Yes, submit'} + + + + +
+
+ )} +
+ ) +} diff --git a/src/app/(admin)/admin/rounds/[roundId]/jurors/[userId]/evaluate/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/jurors/[userId]/evaluate/page.tsx new file mode 100644 index 0000000..af34017 --- /dev/null +++ b/src/app/(admin)/admin/rounds/[roundId]/jurors/[userId]/evaluate/page.tsx @@ -0,0 +1,241 @@ +'use client' + +import { use } from 'react' +import Link from 'next/link' +import type { Route } from 'next' +import { trpc } from '@/lib/trpc/client' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { + ArrowLeft, + ArrowRight, + CheckCircle2, + Clock, + FileEdit, + ShieldAlert, + UserCheck, +} from 'lucide-react' +import { cn } from '@/lib/utils' + +type PageProps = { + params: Promise<{ roundId: string; userId: string }> +} + +export default function AdminJurorProxyEvaluatePage({ params: paramsPromise }: PageProps) { + const { roundId, userId } = use(paramsPromise) + + const { data, isLoading } = trpc.evaluation.getJurorAssignmentsForRound.useQuery({ + roundId, + userId, + }) + + if (isLoading || !data) { + return ( +
+ + + +
+ ) + } + + const { juror, round, assignments } = data + const pending = assignments.filter((a) => a.evaluation?.status !== 'SUBMITTED' && a.evaluation?.status !== 'LOCKED') + const completed = assignments.filter((a) => a.evaluation?.status === 'SUBMITTED' || a.evaluation?.status === 'LOCKED') + + return ( +
+
+ +
+

+ Proxy Evaluations +

+

+ Filling in on behalf of {juror.name || juror.email}{' '} + · {round.name} +

+
+
+ + + + +
+

You are acting on behalf of a juror

+

+ Each submission is recorded in the audit log with your admin ID and the juror's + ID. Voting-window and round-active checks are bypassed for proxy submissions, but + COI-declared projects cannot be proxy-submitted. +

+
+
+
+ + + +
+
+ Pending assignments + + {pending.length === 0 + ? 'No pending evaluations — this juror has completed everything assigned to them.' + : `${pending.length} project${pending.length === 1 ? '' : 's'} awaiting evaluation.`} + +
+ + {completed.length}/{assignments.length} complete + +
+
+ + {pending.length === 0 ? ( +

Nothing to do here.

+ ) : ( + pending.map((a) => ( + + )) + )} +
+
+ + {completed.length > 0 && ( + + + Completed assignments + + Already submitted. Click to view (read-only). + + + + {completed.map((a) => ( + + ))} + + + )} +
+ ) +} + +type AssignmentRowProps = { + roundId: string + userId: string + assignment: { + id: string + isCompleted: boolean + project: { + id: string + title: string + competitionCategory: 'STARTUP' | 'BUSINESS_CONCEPT' | null + teamName: string | null + } + evaluation: { + id: string + status: 'NOT_STARTED' | 'DRAFT' | 'SUBMITTED' | 'LOCKED' + globalScore: number | null + binaryDecision: boolean | null + submittedAt: Date | null + } | null + conflictOfInterest: { id: string; hasConflict: boolean } | null + } + mode: 'pending' | 'completed' +} + +function AssignmentRow({ roundId, userId, assignment, mode }: AssignmentRowProps) { + const { project, evaluation, conflictOfInterest } = assignment + const hasCOI = conflictOfInterest?.hasConflict === true + + const statusLabel = evaluation?.status === 'SUBMITTED' + ? 'Submitted' + : evaluation?.status === 'DRAFT' + ? 'Draft in progress' + : evaluation?.status === 'LOCKED' + ? 'Locked' + : 'Not started' + + const statusColor = + evaluation?.status === 'SUBMITTED' || evaluation?.status === 'LOCKED' + ? 'text-emerald-600' + : evaluation?.status === 'DRAFT' + ? 'text-amber-600' + : 'text-muted-foreground' + + const StatusIcon = + evaluation?.status === 'SUBMITTED' || evaluation?.status === 'LOCKED' + ? CheckCircle2 + : evaluation?.status === 'DRAFT' + ? FileEdit + : Clock + + const href = `/admin/rounds/${roundId}/jurors/${userId}/evaluate/${project.id}` as Route + + return ( +
+
+
+

{project.title}

+ {project.competitionCategory && ( + + {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} + + )} + {hasCOI && ( + + + COI declared + + )} +
+
+ + {statusLabel} + {evaluation?.status === 'SUBMITTED' && evaluation.globalScore !== null && ( + Score: {evaluation.globalScore}/10 + )} + {project.teamName && ( + · {project.teamName} + )} +
+
+ +
+ ) +} 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 d575ba5..650daa7 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 @@ -5,20 +5,19 @@ import { useRouter } from 'next/navigation' import Link from 'next/link' import type { Route } from 'next' import { trpc } from '@/lib/trpc/client' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { Slider } from '@/components/ui/slider' -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 { cn } from '@/lib/utils' import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer' import { Badge } from '@/components/ui/badge' import { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog' -import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert, Lock } from 'lucide-react' +import { ArrowLeft, Save, Send, AlertCircle, Clock, ShieldAlert, Lock } from 'lucide-react' import { toast } from 'sonner' import type { EvaluationConfig } from '@/types/competition-configs' +import { + EvaluationFormFields, + parseCriteriaFromForm, +} from '@/components/evaluation/evaluation-form-fields' type PageProps = { params: Promise<{ roundId: string; projectId: string }> @@ -148,33 +147,10 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { const requireFeedback = evalConfig?.requireFeedback ?? true const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10 - // Parse criteria from the active form - const criteria = (activeForm?.criteriaJson ?? []).map((c) => { - const type = (c as any).type || 'numeric' - let minScore = 1 - let maxScore = 10 - 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] - maxScore = parts[1] - } - } - return { - id: c.id, - label: c.label, - description: c.description, - type: type as 'numeric' | 'text' | 'boolean' | 'advance' | '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 || '', - } - }) + // Parse criteria from the active form (shared with admin proxy flow) + const criteria = parseCriteriaFromForm( + activeForm?.criteriaJson as ReadonlyArray> | null | undefined, + ) // Initialize numeric criteria with midpoint values so slider visual matches stored value. const criteriaInitializedRef = useRef(false) @@ -650,330 +626,23 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { )} - - -
-
- Evaluation Form - - {scoringMode === 'criteria' - ? 'Complete all required fields below' - : `Provide your assessment using the ${scoringMode} scoring method`} - -
- {lastSavedAt && ( - - - Saved {lastSavedAt.toLocaleTimeString()} - - )} -
-
- - {/* Criteria-based scoring with mixed types */} - {scoringMode === 'criteria' && criteria && criteria.length > 0 && ( -
- {criteria.map((criterion) => { - if (criterion.type === 'section_header') { - return ( -
-

{criterion.label}

- {criterion.description && ( -

{criterion.description}

- )} -
- ) - } + - if (criterion.type === 'advance') { - const currentValue = criteriaValues[criterion.id] - return ( -
-
- - {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}

- )} -
-