Jury evaluation UX overhaul + admin review features
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s

- Fix project documents not displaying on jury project page (rewrote MultiWindowDocViewer to use file.listByProject)
- Add working download/preview for project files via presigned URLs
- Display project tags on jury project detail page
- Add autosave for evaluation drafts (debounced 3s + save on unmount/beforeunload)
- Support mixed criterion types: numeric scores, yes/no booleans, text responses, section headers
- Replace inline criteria editor with rich EvaluationFormBuilder on admin round page
- Remove COI dialog from evaluation page
- Update AI summary service to handle boolean/text criteria (yes/no counts, text synthesis)
- Update EvaluationSummaryCard to show boolean criteria bars and text responses
- Add evaluation detail sheet on admin project page (click juror row to view full scores + feedback)
- Add Recent Evaluations dashboard widget showing latest jury reviews

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-18 12:43:28 +01:00
parent 73759eaddd
commit 9ce56f13fd
12 changed files with 1137 additions and 385 deletions

View File

@@ -1,6 +1,6 @@
'use client'
import { use, useState, useEffect } from 'react'
import { use, useState, useEffect, useRef, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
@@ -12,17 +12,9 @@ import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils'
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock } from 'lucide-react'
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2 } from 'lucide-react'
import { toast } from 'sonner'
import type { EvaluationConfig } from '@/types/competition-configs'
@@ -36,15 +28,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const { roundId, projectId } = params
const utils = trpc.useUtils()
const [showCOIDialog, setShowCOIDialog] = useState(true)
const [coiAccepted, setCoiAccepted] = useState(false)
// Evaluation form state
const [criteriaScores, setCriteriaScores] = useState<Record<string, number>>({})
// Evaluation form state — stores all criterion values (numeric, boolean, text)
const [criteriaValues, setCriteriaValues] = useState<Record<string, number | boolean | string>>({})
const [globalScore, setGlobalScore] = useState('')
const [binaryDecision, setBinaryDecision] = useState<'accept' | 'reject' | ''>('')
const [feedbackText, setFeedbackText] = useState('')
// Track dirty state for autosave
const isDirtyRef = useRef(false)
const evaluationIdRef = useRef<string | null>(null)
const isSubmittedRef = useRef(false)
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
// Fetch project
const { data: project } = trpc.project.get.useQuery(
{ id: projectId },
@@ -71,7 +67,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
{ enabled: !!myAssignment?.id }
)
// Fetch the active evaluation form for this round (independent of evaluation existence)
// Fetch the active evaluation form for this round
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId },
{ enabled: !!roundId }
@@ -80,17 +76,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
// Start evaluation mutation (creates draft)
const startMutation = trpc.evaluation.start.useMutation()
// Autosave mutation
// Autosave mutation (silent)
const autosaveMutation = trpc.evaluation.autosave.useMutation({
onSuccess: () => {
toast.success('Draft saved', { duration: 1500 })
isDirtyRef.current = false
setLastSavedAt(new Date())
},
onError: (err) => toast.error(err.message),
})
// Submit mutation
const submitMutation = trpc.evaluation.submit.useMutation({
onSuccess: () => {
isSubmittedRef.current = true
isDirtyRef.current = false
utils.roundAssignment.getMyAssignments.invalidate()
utils.evaluation.get.invalidate()
toast.success('Evaluation submitted successfully')
@@ -99,15 +97,24 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
onError: (err) => toast.error(err.message),
})
// Track evaluation ID
useEffect(() => {
if (existingEvaluation?.id) {
evaluationIdRef.current = existingEvaluation.id
}
}, [existingEvaluation?.id])
// Load existing evaluation data
useEffect(() => {
if (existingEvaluation) {
if (existingEvaluation.criterionScoresJson) {
const scores: Record<string, number> = {}
const values: Record<string, number | boolean | string> = {}
Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => {
scores[key] = typeof value === 'number' ? value : 0
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') {
values[key] = value
}
})
setCriteriaScores(scores)
setCriteriaValues(values)
}
if (existingEvaluation.globalScore) {
setGlobalScore(existingEvaluation.globalScore.toString())
@@ -118,6 +125,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
if (existingEvaluation.feedbackText) {
setFeedbackText(existingEvaluation.feedbackText)
}
isDirtyRef.current = false
}
}, [existingEvaluation])
@@ -127,12 +135,12 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const requireFeedback = evalConfig?.requireFeedback ?? true
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
// Get criteria from the active evaluation form (independent of evaluation record)
// Parse criteria from the active form
const criteria = (activeForm?.criteriaJson ?? []).map((c) => {
// Parse scale string like "1-10" into minScore/maxScore
const type = (c as any).type || 'numeric'
let minScore = 1
let maxScore = 10
if (c.scale) {
if (type === 'numeric' && c.scale) {
const parts = c.scale.split('-').map(Number)
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
minScore = parts[0]
@@ -143,33 +151,135 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
id: c.id,
label: c.label,
description: c.description,
type: type as 'numeric' | 'text' | 'boolean' | 'section_header',
weight: c.weight,
minScore,
maxScore,
required: (c as any).required ?? true,
trueLabel: (c as any).trueLabel || 'Yes',
falseLabel: (c as any).falseLabel || 'No',
maxLength: (c as any).maxLength || 1000,
placeholder: (c as any).placeholder || '',
}
})
// Build current form data for autosave
const buildSavePayload = useCallback(() => {
return {
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : undefined,
globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null,
binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null,
feedbackText: feedbackText || null,
}
}, [scoringMode, criteriaValues, globalScore, binaryDecision, feedbackText])
// Perform autosave
const performAutosave = useCallback(async () => {
if (!isDirtyRef.current || isSubmittedRef.current) return
if (existingEvaluation?.status === 'SUBMITTED') return
let evalId = evaluationIdRef.current
if (!evalId && myAssignment) {
try {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evalId = newEval.id
evaluationIdRef.current = evalId
} catch {
return
}
}
if (!evalId) return
autosaveMutation.mutate({ id: evalId, ...buildSavePayload() })
}, [myAssignment, existingEvaluation?.status, startMutation, autosaveMutation, buildSavePayload])
// Debounced autosave: save 3 seconds after last change
useEffect(() => {
if (!isDirtyRef.current) return
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current)
}
autosaveTimerRef.current = setTimeout(() => {
performAutosave()
}, 3000)
return () => {
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current)
}
}
}, [criteriaValues, globalScore, binaryDecision, feedbackText, performAutosave])
// Save on page leave (beforeunload)
useEffect(() => {
const handleBeforeUnload = () => {
if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) {
const payload = JSON.stringify({
id: evaluationIdRef.current,
...buildSavePayload(),
})
navigator.sendBeacon?.('/api/trpc/evaluation.autosave', payload)
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [buildSavePayload])
// Save on component unmount (navigating away within the app)
useEffect(() => {
return () => {
if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) {
performAutosave()
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Mark dirty when form values change
const handleCriterionChange = (key: string, value: number | boolean | string) => {
setCriteriaValues((prev) => ({ ...prev, [key]: value }))
isDirtyRef.current = true
}
const handleGlobalScoreChange = (value: string) => {
setGlobalScore(value)
isDirtyRef.current = true
}
const handleBinaryChange = (value: 'accept' | 'reject') => {
setBinaryDecision(value)
isDirtyRef.current = true
}
const handleFeedbackChange = (value: string) => {
setFeedbackText(value)
isDirtyRef.current = true
}
const handleSaveDraft = async () => {
if (!myAssignment) {
toast.error('Assignment not found')
return
}
// Create evaluation if it doesn't exist
let evaluationId = existingEvaluation?.id
let evaluationId = evaluationIdRef.current
if (!evaluationId) {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evaluationId = newEval.id
evaluationIdRef.current = evaluationId
}
// Autosave current state
autosaveMutation.mutate({
id: evaluationId,
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : undefined,
globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null,
binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null,
feedbackText: feedbackText || null,
})
autosaveMutation.mutate(
{ id: evaluationId, ...buildSavePayload() },
{ onSuccess: () => {
isDirtyRef.current = false
setLastSavedAt(new Date())
toast.success('Draft saved', { duration: 1500 })
}}
)
}
const handleSubmit = async () => {
@@ -178,17 +288,23 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
return
}
// Validation based on scoring mode
// Validation for criteria mode
if (scoringMode === 'criteria') {
if (!criteria || criteria.length === 0) {
toast.error('No criteria found for this evaluation')
return
}
const requiredCriteria = evalConfig?.requireAllCriteriaScored !== false
if (requiredCriteria) {
const allScored = criteria.every((c) => criteriaScores[c.id] !== undefined)
if (!allScored) {
toast.error('Please score all criteria')
const requiredCriteria = criteria.filter((c) =>
c.type !== 'section_header' && c.required
)
for (const c of requiredCriteria) {
const val = criteriaValues[c.id]
if (c.type === 'numeric' && (val === undefined || val === null)) {
toast.error(`Please score "${c.label}"`)
return
}
if (c.type === 'boolean' && val === undefined) {
toast.error(`Please answer "${c.label}"`)
return
}
if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) {
toast.error(`Please fill in "${c.label}"`)
return
}
}
@@ -216,74 +332,43 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
}
}
// Create evaluation if needed
let evaluationId = existingEvaluation?.id
let evaluationId = evaluationIdRef.current
if (!evaluationId) {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evaluationId = newEval.id
evaluationIdRef.current = evaluationId
}
// Compute a weighted global score from numeric criteria for the global score field
const numericCriteria = criteria.filter((c) => c.type === 'numeric')
let computedGlobalScore = 5
if (scoringMode === 'criteria' && numericCriteria.length > 0) {
let totalWeight = 0
let weightedSum = 0
for (const c of numericCriteria) {
const val = criteriaValues[c.id]
if (typeof val === 'number') {
const w = c.weight ?? 1
// Normalize to 1-10 scale
const normalized = ((val - c.minScore) / (c.maxScore - c.minScore)) * 9 + 1
weightedSum += normalized * w
totalWeight += w
}
}
if (totalWeight > 0) {
computedGlobalScore = Math.round(weightedSum / totalWeight)
}
}
// Submit
submitMutation.mutate({
id: evaluationId,
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : {},
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : 5,
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : {},
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : computedGlobalScore,
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
feedbackText: feedbackText || 'No feedback provided',
})
}
// COI Dialog
if (!coiAccepted && showCOIDialog && evalConfig?.coiRequired !== false) {
return (
<AlertDialog open={showCOIDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Conflict of Interest Declaration</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3 pt-2">
<p>
Before evaluating this project, you must confirm that you have no conflict of
interest.
</p>
<p>
A conflict of interest exists if you have a personal, professional, or financial
relationship with the project team that could influence your judgment.
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex items-start gap-3 py-4">
<Checkbox
id="coi"
checked={coiAccepted}
onCheckedChange={(checked) => setCoiAccepted(checked as boolean)}
/>
<Label htmlFor="coi" className="text-sm leading-relaxed cursor-pointer">
I confirm that I have no conflict of interest with this project and can provide an
unbiased evaluation.
</Label>
</div>
<AlertDialogFooter>
<Button
variant="outline"
onClick={() => router.push(`/jury/competitions/${roundId}` as Route)}
>
Cancel
</Button>
<Button
onClick={() => setShowCOIDialog(false)}
disabled={!coiAccepted}
className="bg-brand-blue hover:bg-brand-blue-light"
>
Continue to Evaluation
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
if (!round || !project) {
return (
<div className="space-y-6">
@@ -299,7 +384,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
)
}
// Check if round is active — round status is the primary gate for evaluations
// Check if round is active
const isRoundActive = round.status === 'ROUND_ACTIVE'
if (!isRoundActive) {
@@ -352,6 +437,9 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</div>
</div>
{/* Project Documents */}
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
<Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-3 p-4">
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
@@ -359,7 +447,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<p className="font-medium text-sm">Important Reminder</p>
<p className="text-sm text-muted-foreground mt-1">
Your evaluation will be used to assess this project. Please provide thoughtful and
constructive feedback to help the team improve.
constructive feedback. Your progress is automatically saved as a draft.
</p>
</div>
</CardContent>
@@ -367,21 +455,116 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<Card>
<CardHeader>
<CardTitle>Evaluation Form</CardTitle>
<CardDescription>
Provide your assessment using the {scoringMode} scoring method
</CardDescription>
<div className="flex items-start justify-between">
<div>
<CardTitle>Evaluation Form</CardTitle>
<CardDescription>
{scoringMode === 'criteria'
? 'Complete all required fields below'
: `Provide your assessment using the ${scoringMode} scoring method`}
</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">
{/* Criteria-based scoring */}
{/* Criteria-based scoring with mixed types */}
{scoringMode === 'criteria' && criteria && criteria.length > 0 && (
<div className="space-y-4">
<h3 className="font-semibold">Criteria Scores</h3>
{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 === '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"
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'
)}
>
<ThumbsUp className="mr-2 h-4 w-4" />
{criterion.trueLabel}
</button>
<button
type="button"
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'
)}
>
<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}
/>
<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 = criteriaScores[criterion.id]
const displayValue = currentValue !== undefined ? currentValue : undefined
const currentValue = criteriaValues[criterion.id]
const displayValue = typeof currentValue === 'number' ? currentValue : undefined
const sliderValue = typeof currentValue === 'number' ? currentValue : Math.ceil((min + max) / 2)
return (
@@ -390,16 +573,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<div className="space-y-1">
<Label className="text-base font-medium">
{criterion.label}
{evalConfig?.requireAllCriteriaScored !== false && (
<span className="text-destructive ml-1">*</span>
)}
{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}
{displayValue !== undefined ? displayValue : '\u2014'}/{max}
</span>
</div>
@@ -410,9 +591,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
max={max}
step={1}
value={[sliderValue]}
onValueChange={(v) =>
setCriteriaScores({ ...criteriaScores, [criterion.id]: v[0] })
}
onValueChange={(v) => handleCriterionChange(criterion.id, v[0])}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-4">{max}</span>
@@ -423,9 +602,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<button
key={num}
type="button"
onClick={() =>
setCriteriaScores({ ...criteriaScores, [criterion.id]: num })
}
onClick={() => handleCriterionChange(criterion.id, num)}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
displayValue !== undefined && displayValue === num
@@ -453,7 +630,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
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
{globalScore || '\u2014'}/10
</span>
</div>
<div className="flex items-center gap-2">
@@ -463,7 +640,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
max={10}
step={1}
value={[globalScore ? parseInt(globalScore, 10) : 5]}
onValueChange={(v) => setGlobalScore(v[0].toString())}
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
className="flex-1"
/>
<span className="text-xs text-muted-foreground">10</span>
@@ -475,7 +652,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<button
key={num}
type="button"
onClick={() => setGlobalScore(num.toString())}
onClick={() => handleGlobalScoreChange(num.toString())}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
current === num
@@ -490,9 +667,6 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
)
})}
</div>
<p className="text-xs text-muted-foreground">
Provide a score from 1 to 10 based on your overall assessment
</p>
</div>
)}
@@ -502,7 +676,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<Label>
Decision <span className="text-destructive">*</span>
</Label>
<RadioGroup value={binaryDecision} onValueChange={(v) => setBinaryDecision(v as 'accept' | 'reject')}>
<RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')}>
<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">
@@ -524,13 +698,13 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
{/* Feedback */}
<div className="space-y-2">
<Label htmlFor="feedbackText">
Feedback
General Comment / Feedback
{requireFeedback && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id="feedbackText"
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)}
onChange={(e) => handleFeedbackChange(e.target.value)}
placeholder="Provide your feedback on the project..."
rows={8}
/>

View File

@@ -9,8 +9,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
import { ArrowLeft, FileText, Users, MapPin, Target } from 'lucide-react'
import { toast } from 'sonner'
import { ArrowLeft, FileText, Users, MapPin, Target, Tag } from 'lucide-react'
export default function JuryProjectDetailPage() {
const params = useParams()
@@ -105,15 +104,41 @@ export default function JuryProjectDetailPage() {
{project.competitionCategory && (
<Badge variant="outline">{project.competitionCategory}</Badge>
)}
{project.tags && project.tags.length > 0 && (
project.tags.slice(0, 3).map((tag: string) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))
)}
</div>
{/* Project tags */}
{((project.projectTags && project.projectTags.length > 0) ||
(project.tags && project.tags.length > 0)) && (
<div>
<h3 className="font-semibold mb-2 flex items-center gap-2">
<Tag className="h-4 w-4" />
Tags
</h3>
<div className="flex flex-wrap gap-2">
{project.projectTags && project.projectTags.length > 0
? project.projectTags.map((pt: any) => (
<Badge
key={pt.id}
variant="secondary"
style={pt.tag.color ? { backgroundColor: pt.tag.color + '20', borderColor: pt.tag.color, color: pt.tag.color } : undefined}
className="text-xs"
>
{pt.tag.name}
{pt.tag.category && (
<span className="ml-1 opacity-60">({pt.tag.category})</span>
)}
</Badge>
))
: project.tags.map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))
}
</div>
</div>
)}
{/* Description */}
{project.description && (
<div>