Compare commits
2 Commits
f1955b68f9
...
9cb3b9de13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cb3b9de13 | ||
|
|
fd4f6dde16 |
@@ -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<Record<string, number | boolean | string>>({})
|
||||||
|
const [globalScore, setGlobalScore] = useState('')
|
||||||
|
const [binaryDecision, setBinaryDecision] = useState<'' | 'accept' | 'reject'>('')
|
||||||
|
const [feedbackText, setFeedbackText] = useState('')
|
||||||
|
|
||||||
|
const isDirtyRef = useRef(false)
|
||||||
|
const evaluationIdRef = useRef<string | null>(null)
|
||||||
|
const isSubmittedRef = useRef(false)
|
||||||
|
const isSubmittingRef = useRef(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const startPromiseRef = useRef<Promise<{ id: string }> | null>(null)
|
||||||
|
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(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<string, number | boolean | string> = {}
|
||||||
|
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<Record<string, unknown>> | 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<string, number | boolean | string> = {}
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-10 w-64" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assignment || !project) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={backHref}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Assignments
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Card className="border-l-4 border-l-red-500">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="font-semibold">Assignment not found</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
This juror does not have an assignment for this project in the selected round.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSubmittedEvaluation = existingEvaluation?.status === 'SUBMITTED' || existingEvaluation?.status === 'LOCKED'
|
||||||
|
const isReadOnly = isSubmittedEvaluation
|
||||||
|
const hasCOI = assignment.conflictOfInterest?.hasConflict === true
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={backHref}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Assignments
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||||
|
{isReadOnly ? 'Submitted Evaluation' : 'Fill In Evaluation'}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
|
<p className="text-muted-foreground">{project.title}</p>
|
||||||
|
{project.competitionCategory && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={
|
||||||
|
project.competitionCategory === 'STARTUP'
|
||||||
|
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
||||||
|
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-l-4 border-l-amber-500 bg-amber-50/40 dark:bg-amber-950/10">
|
||||||
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
|
<UserCheck className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 text-sm">
|
||||||
|
<p className="font-medium">
|
||||||
|
Acting on behalf of {juror.name || juror.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isReadOnly && (
|
||||||
|
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20">
|
||||||
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
|
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-sm">View-Only</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
This evaluation has already been submitted. To change it, reset via the
|
||||||
|
round dashboard first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasCOI && !isReadOnly && (
|
||||||
|
<Card className="border-l-4 border-l-red-500 bg-red-50/40 dark:bg-red-950/10">
|
||||||
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
|
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 text-sm">
|
||||||
|
<p className="font-medium">Conflict of interest declared</p>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
The juror declared a conflict on this project. Reassign via the COI review
|
||||||
|
workflow instead of submitting a proxy evaluation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
|
||||||
|
|
||||||
|
<EvaluationFormFields
|
||||||
|
criteria={criteria}
|
||||||
|
scoringMode={scoringMode}
|
||||||
|
requireFeedback={requireFeedback}
|
||||||
|
feedbackMinLength={feedbackMinLength}
|
||||||
|
criteriaValues={criteriaValues}
|
||||||
|
globalScore={globalScore}
|
||||||
|
binaryDecision={binaryDecision}
|
||||||
|
feedbackText={feedbackText}
|
||||||
|
isReadOnly={isReadOnly || hasCOI}
|
||||||
|
lastSavedAt={lastSavedAt}
|
||||||
|
onCriterionChange={handleCriterionChange}
|
||||||
|
onGlobalScoreChange={handleGlobalScoreChange}
|
||||||
|
onBinaryChange={handleBinaryChange}
|
||||||
|
onFeedbackChange={handleFeedbackChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isReadOnly ? (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={backHref}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={backHref}>Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSaveDraft}
|
||||||
|
disabled={autosaveMutation.isPending || submitMutation.isPending || hasCOI}
|
||||||
|
>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{autosaveMutation.isPending ? 'Saving...' : 'Save Draft'}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={handleOpenConfirm}
|
||||||
|
disabled={submitMutation.isPending || isSubmitting || hasCOI}
|
||||||
|
className="bg-brand-blue hover:bg-brand-blue-light"
|
||||||
|
>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
{submitMutation.isPending ? 'Submitting...' : 'Submit on behalf'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Submit on behalf of {juror.name || juror.email}?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
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.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isSubmitting}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleConfirmedSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="bg-brand-blue hover:bg-brand-blue-light"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Submitting...' : 'Yes, submit'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-10 w-64" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/admin/rounds/${roundId}` as Route}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Round
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||||
|
Proxy Evaluations
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Filling in on behalf of <span className="font-semibold">{juror.name || juror.email}</span>{' '}
|
||||||
|
· {round.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-l-4 border-l-amber-500">
|
||||||
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
|
<UserCheck className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 text-sm">
|
||||||
|
<p className="font-medium">You are acting on behalf of a juror</p>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">Pending assignments</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{pending.length === 0
|
||||||
|
? 'No pending evaluations — this juror has completed everything assigned to them.'
|
||||||
|
: `${pending.length} project${pending.length === 1 ? '' : 's'} awaiting evaluation.`}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
|
{completed.length}/{assignments.length} complete
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{pending.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">Nothing to do here.</p>
|
||||||
|
) : (
|
||||||
|
pending.map((a) => (
|
||||||
|
<AssignmentRow
|
||||||
|
key={a.id}
|
||||||
|
roundId={roundId}
|
||||||
|
userId={userId}
|
||||||
|
assignment={a}
|
||||||
|
mode="pending"
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{completed.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Completed assignments</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Already submitted. Click to view (read-only).
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{completed.map((a) => (
|
||||||
|
<AssignmentRow
|
||||||
|
key={a.id}
|
||||||
|
roundId={roundId}
|
||||||
|
userId={userId}
|
||||||
|
assignment={a}
|
||||||
|
mode="completed"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between gap-3 p-3 rounded-lg border transition-colors',
|
||||||
|
mode === 'pending' ? 'hover:bg-muted/30' : 'bg-muted/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="font-medium text-sm truncate">{project.title}</p>
|
||||||
|
{project.competitionCategory && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
'shrink-0',
|
||||||
|
project.competitionCategory === 'STARTUP'
|
||||||
|
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
||||||
|
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{hasCOI && (
|
||||||
|
<Badge variant="destructive" className="shrink-0 gap-1">
|
||||||
|
<ShieldAlert className="h-3 w-3" />
|
||||||
|
COI declared
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={cn('flex items-center gap-1.5 text-xs mt-1', statusColor)}>
|
||||||
|
<StatusIcon className="h-3.5 w-3.5" />
|
||||||
|
<span>{statusLabel}</span>
|
||||||
|
{evaluation?.status === 'SUBMITTED' && evaluation.globalScore !== null && (
|
||||||
|
<span className="ml-2 text-muted-foreground">Score: {evaluation.globalScore}/10</span>
|
||||||
|
)}
|
||||||
|
{project.teamName && (
|
||||||
|
<span className="ml-2 text-muted-foreground">· {project.teamName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant={mode === 'pending' ? 'default' : 'outline'} asChild disabled={hasCOI}>
|
||||||
|
<Link href={href} aria-disabled={hasCOI} className={hasCOI ? 'pointer-events-none opacity-50' : ''}>
|
||||||
|
{mode === 'pending' ? 'Fill in' : 'View'}
|
||||||
|
<ArrowRight className="ml-1.5 h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,20 +5,19 @@ import { useRouter } from 'next/navigation'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
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 { 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 { 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 { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog'
|
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 { toast } from 'sonner'
|
||||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||||
|
import {
|
||||||
|
EvaluationFormFields,
|
||||||
|
parseCriteriaFromForm,
|
||||||
|
} from '@/components/evaluation/evaluation-form-fields'
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: Promise<{ roundId: string; projectId: string }>
|
params: Promise<{ roundId: string; projectId: string }>
|
||||||
@@ -148,33 +147,10 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
const requireFeedback = evalConfig?.requireFeedback ?? true
|
const requireFeedback = evalConfig?.requireFeedback ?? true
|
||||||
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
|
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
|
||||||
|
|
||||||
// Parse criteria from the active form
|
// Parse criteria from the active form (shared with admin proxy flow)
|
||||||
const criteria = (activeForm?.criteriaJson ?? []).map((c) => {
|
const criteria = parseCriteriaFromForm(
|
||||||
const type = (c as any).type || 'numeric'
|
activeForm?.criteriaJson as ReadonlyArray<Record<string, unknown>> | null | undefined,
|
||||||
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 || '',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Initialize numeric criteria with midpoint values so slider visual matches stored value.
|
// Initialize numeric criteria with midpoint values so slider visual matches stored value.
|
||||||
const criteriaInitializedRef = useRef(false)
|
const criteriaInitializedRef = useRef(false)
|
||||||
@@ -650,330 +626,23 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
<EvaluationFormFields
|
||||||
<CardHeader>
|
criteria={criteria}
|
||||||
<div className="flex items-start justify-between">
|
scoringMode={scoringMode}
|
||||||
<div>
|
requireFeedback={requireFeedback}
|
||||||
<CardTitle>Evaluation Form</CardTitle>
|
feedbackMinLength={feedbackMinLength}
|
||||||
<CardDescription>
|
criteriaValues={criteriaValues}
|
||||||
{scoringMode === 'criteria'
|
globalScore={globalScore}
|
||||||
? 'Complete all required fields below'
|
binaryDecision={binaryDecision}
|
||||||
: `Provide your assessment using the ${scoringMode} scoring method`}
|
feedbackText={feedbackText}
|
||||||
</CardDescription>
|
isReadOnly={isReadOnly}
|
||||||
</div>
|
lastSavedAt={lastSavedAt}
|
||||||
{lastSavedAt && (
|
onCriterionChange={handleCriterionChange}
|
||||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
onGlobalScoreChange={handleGlobalScoreChange}
|
||||||
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
onBinaryChange={handleBinaryChange}
|
||||||
Saved {lastSavedAt.toLocaleTimeString()}
|
onFeedbackChange={handleFeedbackChange}
|
||||||
</span>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Criteria-based scoring with mixed types */}
|
|
||||||
{scoringMode === 'criteria' && 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={() => 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',
|
|
||||||
isReadOnly && 'opacity-60 cursor-default'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ThumbsUp className="mr-2 h-5 w-5" />
|
|
||||||
{criterion.trueLabel || 'Yes'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isReadOnly}
|
|
||||||
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',
|
|
||||||
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={() => handleCriterionChange(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
|
|
||||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
|
|
||||||
: '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={() => handleCriterionChange(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
|
|
||||||
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
|
|
||||||
: '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) => handleCriterionChange(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 : '\u2014'}/{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) => handleCriterionChange(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={() => handleCriterionChange(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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Global scoring */}
|
|
||||||
{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 || '\u2014'}/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) => handleGlobalScoreChange(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={() => handleGlobalScoreChange(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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Binary decision */}
|
|
||||||
{scoringMode === 'binary' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>
|
|
||||||
Decision <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Feedback */}
|
|
||||||
<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) => handleFeedbackChange(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>
|
|
||||||
|
|
||||||
{isReadOnly ? (
|
{isReadOnly ? (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Loader2, Mail, ArrowRightLeft, UserPlus, Trash2 } from 'lucide-react'
|
import { Loader2, Mail, ArrowRightLeft, UserPlus, Trash2, FilePen } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
import { TransferAssignmentsDialog } from './transfer-assignments-dialog'
|
import { TransferAssignmentsDialog } from './transfer-assignments-dialog'
|
||||||
import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap'
|
import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap'
|
||||||
|
|
||||||
@@ -186,6 +188,24 @@ export function JuryProgressTable({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href={`/admin/rounds/${roundId}/jurors/${juror.id}/evaluate` as Route}>
|
||||||
|
<FilePen className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left"><p>Fill in evaluations on behalf of this juror</p></TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ export function RoundUnassignedQueue({ roundId, requiredReviews = 3, onAssignUna
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium truncate">{project.title}</p>
|
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{project.competitionCategory || 'No category'}
|
{project.category === 'STARTUP'
|
||||||
|
? 'Startup'
|
||||||
|
: project.category === 'BUSINESS_CONCEPT'
|
||||||
|
? 'Business Concept'
|
||||||
|
: 'No category'}
|
||||||
{project.teamName && ` \u00b7 ${project.teamName}`}
|
{project.teamName && ` \u00b7 ${project.teamName}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
432
src/components/evaluation/evaluation-form-fields.tsx
Normal file
432
src/components/evaluation/evaluation-form-fields.tsx
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
'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
|
||||||
|
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
|
||||||
|
: '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
|
||||||
|
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
|
||||||
|
: '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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1881,4 +1881,288 @@ export const evaluationRouter = router({
|
|||||||
evaluation: a.evaluation!,
|
evaluation: a.evaluation!,
|
||||||
}))
|
}))
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Admin Proxy Evaluation — fill in evaluations on behalf of a juror who
|
||||||
|
// could not submit themselves (e.g. access issues). Bypasses voting window
|
||||||
|
// and ownership checks. Every proxy submission is audit-logged with both
|
||||||
|
// the admin userId and the juror userId whose assignment was completed.
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin: start (or reuse) a draft evaluation for any juror's assignment.
|
||||||
|
*/
|
||||||
|
adminStart: adminProcedure
|
||||||
|
.input(z.object({ assignmentId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
|
||||||
|
where: { id: input.assignmentId },
|
||||||
|
include: { project: { select: { competitionCategory: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = await findActiveForm(
|
||||||
|
ctx.prisma,
|
||||||
|
assignment.roundId,
|
||||||
|
assignment.project.competitionCategory,
|
||||||
|
)
|
||||||
|
if (!form) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'No active evaluation form for this stage',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await ctx.prisma.evaluation.findUnique({
|
||||||
|
where: { assignmentId: input.assignmentId },
|
||||||
|
})
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
return ctx.prisma.evaluation.create({
|
||||||
|
data: {
|
||||||
|
assignmentId: input.assignmentId,
|
||||||
|
formId: form.id,
|
||||||
|
status: 'DRAFT',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin: autosave a draft evaluation. No ownership check; refuses if the
|
||||||
|
* evaluation has already been SUBMITTED or LOCKED.
|
||||||
|
*/
|
||||||
|
adminAutosave: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])).optional(),
|
||||||
|
globalScore: z.number().int().min(1).max(10).optional().nullable(),
|
||||||
|
binaryDecision: z.boolean().optional().nullable(),
|
||||||
|
feedbackText: z.string().optional().nullable(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { id, ...data } = input
|
||||||
|
const evaluation = await ctx.prisma.evaluation.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
})
|
||||||
|
if (evaluation.status === 'SUBMITTED' || evaluation.status === 'LOCKED') {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Cannot edit submitted evaluation',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ctx.prisma.evaluation.update({
|
||||||
|
where: { id },
|
||||||
|
data: { ...data, status: 'DRAFT' },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin: submit an evaluation on behalf of a juror. Bypasses round-active
|
||||||
|
* and voting-window checks. Still enforces COI (juror-declared conflicts
|
||||||
|
* must be resolved via reassignment) and feedback/criterion validation.
|
||||||
|
*/
|
||||||
|
adminSubmitOnBehalf: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])),
|
||||||
|
globalScore: z.number().int().min(1).max(10).optional(),
|
||||||
|
binaryDecision: z.boolean().optional(),
|
||||||
|
feedbackText: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { id, ...data } = input
|
||||||
|
|
||||||
|
const evaluation = await ctx.prisma.evaluation.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
assignment: true,
|
||||||
|
form: { select: { criteriaJson: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// COI still blocks: if the juror declared a conflict, the correct path
|
||||||
|
// is to reassign, not to proxy-submit.
|
||||||
|
const coi = await ctx.prisma.conflictOfInterest.findFirst({
|
||||||
|
where: { assignmentId: evaluation.assignmentId, hasConflict: true },
|
||||||
|
})
|
||||||
|
if (coi) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Cannot submit — juror declared a conflict of interest. Reassign this project first.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: evaluation.assignment.roundId },
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = (round.configJson as Record<string, unknown>) || {}
|
||||||
|
const scoringMode = (config.scoringMode as string) || 'criteria'
|
||||||
|
|
||||||
|
const requireFeedback = config.requireFeedback !== false
|
||||||
|
if (requireFeedback) {
|
||||||
|
const feedbackMinLength = (config.feedbackMinLength as number) || 10
|
||||||
|
if (!data.feedbackText || data.feedbackText.length < feedbackMinLength) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `Feedback must be at least ${feedbackMinLength} characters`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scoringMode !== 'binary') {
|
||||||
|
data.binaryDecision = undefined
|
||||||
|
}
|
||||||
|
if (scoringMode === 'binary') {
|
||||||
|
data.globalScore = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.requireAllCriteriaScored && scoringMode === 'criteria') {
|
||||||
|
const evalForm = evaluation.form
|
||||||
|
if (evalForm?.criteriaJson) {
|
||||||
|
const criteria = evalForm.criteriaJson as Array<{ id: string; label?: string; type?: string; required?: boolean }>
|
||||||
|
const scorableCriteria = criteria.filter(
|
||||||
|
(c) => c.type !== 'section_header' && c.type !== 'text' && c.required !== false
|
||||||
|
)
|
||||||
|
const scores = data.criterionScoresJson as Record<string, unknown> | undefined
|
||||||
|
const missingCriteria = scorableCriteria.filter((c) => {
|
||||||
|
if (!scores) return true
|
||||||
|
const val = scores[c.id]
|
||||||
|
if (c.type === 'boolean' || c.type === 'advance') return typeof val !== 'boolean'
|
||||||
|
return typeof val !== 'number'
|
||||||
|
})
|
||||||
|
if (missingCriteria.length > 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `Missing scores for criteria: ${missingCriteria.map((c) => c.label || c.id).join(', ')}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const saveData = {
|
||||||
|
criterionScoresJson: data.criterionScoresJson,
|
||||||
|
globalScore: data.globalScore ?? null,
|
||||||
|
binaryDecision: data.binaryDecision ?? null,
|
||||||
|
feedbackText: data.feedbackText ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await ctx.prisma.$transaction([
|
||||||
|
ctx.prisma.evaluation.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...saveData,
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
submittedAt: now,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.prisma.assignment.update({
|
||||||
|
where: { id: evaluation.assignmentId },
|
||||||
|
data: { isCompleted: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
triggerAutoRankIfComplete(evaluation.assignment.roundId, ctx.prisma, ctx.user.id).catch((err) => {
|
||||||
|
console.error('[Evaluation] triggerAutoRankIfComplete failed (admin proxy):', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
await triggerInProgressOnActivity(
|
||||||
|
evaluation.assignment.projectId,
|
||||||
|
evaluation.assignment.roundId,
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.prisma,
|
||||||
|
)
|
||||||
|
await checkEvaluationCompletionAndTransition(
|
||||||
|
evaluation.assignment.projectId,
|
||||||
|
evaluation.assignment.roundId,
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.prisma,
|
||||||
|
)
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'ADMIN_PROXY_EVAL_SUBMITTED',
|
||||||
|
entityType: 'Evaluation',
|
||||||
|
entityId: id,
|
||||||
|
detailsJson: {
|
||||||
|
adminUserId: ctx.user.id,
|
||||||
|
onBehalfOfUserId: evaluation.assignment.userId,
|
||||||
|
assignmentId: evaluation.assignmentId,
|
||||||
|
projectId: evaluation.assignment.projectId,
|
||||||
|
roundId: evaluation.assignment.roundId,
|
||||||
|
globalScore: data.globalScore,
|
||||||
|
binaryDecision: data.binaryDecision,
|
||||||
|
windowCloseAt: round.windowCloseAt,
|
||||||
|
roundStatus: round.status,
|
||||||
|
bypassedWindow: round.status !== 'ROUND_ACTIVE' || (round.windowCloseAt ? now > round.windowCloseAt : false),
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin: list all assignments for a specific juror in a round, including
|
||||||
|
* project details and current evaluation status. Drives the admin proxy-
|
||||||
|
* evaluation flow (list → pick project → fill in).
|
||||||
|
*/
|
||||||
|
getJurorAssignmentsForRound: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string(), userId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const [juror, round, assignments] = await Promise.all([
|
||||||
|
ctx.prisma.user.findUnique({
|
||||||
|
where: { id: input.userId },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.round.findUnique({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { id: true, name: true, roundType: true, status: true, windowCloseAt: true, competitionId: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.assignment.findMany({
|
||||||
|
where: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
userId: input.userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
competitionCategory: true,
|
||||||
|
teamName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
evaluation: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
globalScore: true,
|
||||||
|
binaryDecision: true,
|
||||||
|
submittedAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conflictOfInterest: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
hasConflict: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ isCompleted: 'asc' }, { project: { title: 'asc' } }],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!juror || !round) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Juror or round not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { juror, round, assignments }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user