feat: admin can fill in evaluations on behalf of jurors
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m54s
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m54s
When a juror cannot connect during an evaluation round, an admin can now submit evaluations for them. Router — new admin procedures: - adminStart / adminAutosave: create and save drafts for any juror. - adminSubmitOnBehalf: submit bypassing ROUND_ACTIVE and voting-window checks. COI block and feedback/criterion validation still enforced. Audit log records both admin and juror IDs plus bypassedWindow flag. - getJurorAssignmentsForRound: list a juror's assignments + eval state. UI — two new admin pages under /admin/rounds/[roundId]/jurors/[userId]/: - evaluate: list of pending + completed assignments, COI flagged. - evaluate/[projectId]: evaluation form reusing the juror's scoring UI, with an "acting on behalf" banner and confirmation dialog before submit. Back button returns to the assignments list. Entry point: FilePen icon on each juror row in JuryProgressTable. Refactor: extracted the scoring form JSX into shared EvaluationFormFields component so the juror page and the admin proxy page render identical inputs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user