Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m23s

- Phase 1: 7 round config sub-components covering all ~65 Zod schema fields across INTAKE, FILTERING, EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION
- Phase 2: Replace Competitions nav with Rounds + add Juries; new /admin/rounds and /admin/rounds/[roundId] pages with tabbed detail (Config, Projects, Windows, Documents, Awards)
- Phase 3: Top-level /admin/juries with list + detail pages (members table, settings panel, self-service review)
- Phase 4: File requirements editor in round config; project detail per-requirement upload slots replacing generic drop zone
- Phase 5: Awards edit page with source round dropdown, eligibility mode, auto-tag rules builder; round detail Awards tab; specialAward router enhanced with evaluationRoundId/eligibilityMode fields
- Phase 6: Evaluation page rewrite supporting all 3 scoring modes (criteria/global/binary) with config-driven behavior; live voting UI polish
- Phase 7: UI design polish across admin pages — consistent headers, cards, hover transitions, empty states, brand colors
- Bulk upload page for admin project imports
- File router enhanced with admin upload and submission window procedures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 01:16:55 +01:00
parent fbb194067d
commit 4c0efb232c
23 changed files with 5745 additions and 891 deletions

View File

@@ -1,48 +1,69 @@
'use client';
'use client'
import { use, useState } from 'react';
import { trpc } from '@/lib/trpc/client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { LiveVotingForm } from '@/components/jury/live-voting-form';
import { toast } from 'sonner';
import { use, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { LiveVotingForm } from '@/components/jury/live-voting-form'
import { toast } from 'sonner'
export default function JuryLivePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
const params = use(paramsPromise);
const utils = trpc.useUtils();
const [notes, setNotes] = useState('');
const [priorDataOpen, setPriorDataOpen] = useState(false);
const params = use(paramsPromise)
const utils = trpc.useUtils()
const [notes, setNotes] = useState('')
const [priorDataOpen, setPriorDataOpen] = useState(false)
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId });
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId })
// Fetch live voting session data
const { data: sessionData } = trpc.liveVoting.getSessionForVoting.useQuery(
{ sessionId: params.roundId },
{ enabled: !!params.roundId, refetchInterval: 2000 }
)
// Placeholder for prior data - this would need to be implemented in evaluation router
const priorData = null as { averageScore?: number; evaluationCount?: number; strengths?: string; weaknesses?: string } | null;
const priorData = null as { averageScore?: number; evaluationCount?: number; strengths?: string; weaknesses?: string } | null
const submitVoteMutation = trpc.liveVoting.vote.useMutation({
onSuccess: () => {
toast.success('Vote submitted successfully');
utils.liveVoting.getSessionForVoting.invalidate()
toast.success('Vote submitted successfully')
},
onError: (err: any) => {
toast.error(err.message);
}
});
toast.error(err.message)
},
})
const handleVoteSubmit = (vote: { score: number }) => {
if (!cursor?.activeProject?.id) return;
const handleVoteSubmit = (vote: { score: number; criterionScores?: Record<string, number> }) => {
const projectId = cursor?.activeProject?.id || sessionData?.currentProject?.id
if (!projectId) return
const sessionId = sessionData?.session?.id || params.roundId
submitVoteMutation.mutate({
sessionId: params.roundId,
projectId: cursor.activeProject.id,
score: vote.score
});
};
sessionId,
projectId,
score: vote.score,
criterionScores: vote.criterionScores,
})
}
if (!cursor?.activeProject) {
// Extract voting mode and criteria from session
const votingMode = (sessionData?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
const criteria = (sessionData?.session?.criteriaJson as Array<{
id: string
label: string
description?: string
scale: number
weight: number
}> | undefined)
const activeProject = cursor?.activeProject || sessionData?.currentProject
if (!activeProject) {
return (
<div className="space-y-6">
<Card>
@@ -54,7 +75,7 @@ export default function JuryLivePage({ params: paramsPromise }: { params: Promis
</CardContent>
</Card>
</div>
);
)
}
return (
@@ -64,16 +85,19 @@ export default function JuryLivePage({ params: paramsPromise }: { params: Promis
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-2xl">{cursor.activeProject.title}</CardTitle>
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
<CardDescription className="mt-2">
Live project presentation
</CardDescription>
</div>
{votingMode === 'criteria' && (
<Badge variant="secondary">Criteria Voting</Badge>
)}
</div>
</CardHeader>
<CardContent>
{cursor.activeProject.description && (
<p className="text-muted-foreground">{cursor.activeProject.description}</p>
{activeProject.description && (
<p className="text-muted-foreground">{activeProject.description}</p>
)}
</CardContent>
</Card>
@@ -144,10 +168,16 @@ export default function JuryLivePage({ params: paramsPromise }: { params: Promis
{/* Voting Form */}
<LiveVotingForm
projectId={cursor.activeProject.id}
projectId={activeProject.id}
votingMode={votingMode}
criteria={criteria}
existingVote={sessionData?.userVote ? {
score: sessionData.userVote.score,
criterionScoresJson: sessionData.userVote.criterionScoresJson as Record<string, number> | undefined
} : null}
onVoteSubmit={handleVoteSubmit}
disabled={submitVoteMutation.isPending}
/>
</div>
);
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { use, useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
@@ -11,6 +11,7 @@ import { Input } from '@/components/ui/input'
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 {
Dialog,
DialogContent,
@@ -20,66 +21,201 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { ArrowLeft, Save, Send, AlertCircle } from 'lucide-react'
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown } from 'lucide-react'
import { toast } from 'sonner'
import type { EvaluationConfig } from '@/types/competition-configs'
export default function JuryEvaluatePage() {
const params = useParams()
type PageProps = {
params: Promise<{ roundId: string; projectId: string }>
}
export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const params = use(paramsPromise)
const router = useRouter()
const roundId = params.roundId as string
const projectId = params.projectId as string
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>>({})
const [globalScore, setGlobalScore] = useState('')
const [feedbackGeneral, setFeedbackGeneral] = useState('')
const [feedbackStrengths, setFeedbackStrengths] = useState('')
const [feedbackWeaknesses, setFeedbackWeaknesses] = useState('')
const utils = trpc.useUtils()
const [binaryDecision, setBinaryDecision] = useState<'accept' | 'reject' | ''>('')
const [feedbackText, setFeedbackText] = useState('')
// Fetch project
const { data: project } = trpc.project.get.useQuery(
{ id: projectId },
{ enabled: !!projectId }
)
// Fetch round to get config
const { data: round } = trpc.round.getById.useQuery(
{ id: roundId },
{ enabled: !!roundId }
)
// Fetch assignment to get evaluation
const { data: assignment } = trpc.roundAssignment.getMyAssignments.useQuery(
{ roundId },
{ enabled: !!roundId }
)
const myAssignment = assignment?.find((a) => a.projectId === projectId)
// Fetch existing evaluation if it exists
const { data: existingEvaluation } = trpc.evaluation.get.useQuery(
{ assignmentId: myAssignment?.id ?? '' },
{ enabled: !!myAssignment?.id }
)
// Start evaluation mutation (creates draft)
const startMutation = trpc.evaluation.start.useMutation()
// Autosave mutation
const autosaveMutation = trpc.evaluation.autosave.useMutation({
onSuccess: () => {
toast.success('Draft saved', { duration: 1500 })
},
onError: (err) => toast.error(err.message),
})
// Submit mutation
const submitMutation = trpc.evaluation.submit.useMutation({
onSuccess: () => {
utils.roundAssignment.getMyAssignments.invalidate()
utils.evaluation.get.invalidate()
toast.success('Evaluation submitted successfully')
router.push(`/jury/competitions/${roundId}` as Route)
},
onError: (err) => toast.error(err.message),
})
const handleSubmit = () => {
const score = parseInt(globalScore)
if (isNaN(score) || score < 1 || score > 10) {
toast.error('Please enter a valid score between 1 and 10')
// Load existing evaluation data
useEffect(() => {
if (existingEvaluation) {
if (existingEvaluation.criterionScoresJson) {
const scores: Record<string, number> = {}
Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => {
scores[key] = typeof value === 'number' ? value : 0
})
setCriteriaScores(scores)
}
if (existingEvaluation.globalScore) {
setGlobalScore(existingEvaluation.globalScore.toString())
}
if (existingEvaluation.binaryDecision !== null) {
setBinaryDecision(existingEvaluation.binaryDecision ? 'accept' : 'reject')
}
if (existingEvaluation.feedbackText) {
setFeedbackText(existingEvaluation.feedbackText)
}
}
}, [existingEvaluation])
// Parse evaluation config from round
const evalConfig: EvaluationConfig | null = round?.configJson as EvaluationConfig | null
const scoringMode = evalConfig?.scoringMode ?? 'global'
const requireFeedback = evalConfig?.requireFeedback ?? true
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
// Get criteria from evaluation form
const criteria = existingEvaluation?.form?.criteriaJson as Array<{
id: string
label: string
description?: string
weight?: number
minScore?: number
maxScore?: number
}> | undefined
const handleSaveDraft = async () => {
if (!myAssignment) {
toast.error('Assignment not found')
return
}
if (!feedbackGeneral.trim() || feedbackGeneral.length < 10) {
toast.error('Please provide general feedback (minimum 10 characters)')
return
// Create evaluation if it doesn't exist
let evaluationId = existingEvaluation?.id
if (!evaluationId) {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evaluationId = newEval.id
}
// In a real implementation, we would first get or create the evaluation ID
// For now, this is a placeholder that shows the structure
toast.error('Evaluation submission requires an existing evaluation ID. This feature needs backend integration.')
/* Real implementation would be:
submitMutation.mutate({
id: evaluationId, // From assignment.evaluation.id
criterionScoresJson: {}, // Criterion scores
globalScore: score,
binaryDecision: true,
feedbackText: feedbackGeneral,
// 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,
})
*/
}
if (!coiAccepted && showCOIDialog) {
const handleSubmit = async () => {
if (!myAssignment) {
toast.error('Assignment not found')
return
}
// Validation based on scoring 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')
return
}
}
}
if (scoringMode === 'global') {
const score = parseInt(globalScore, 10)
if (isNaN(score) || score < 1 || score > 10) {
toast.error('Please enter a valid score between 1 and 10')
return
}
}
if (scoringMode === 'binary') {
if (!binaryDecision) {
toast.error('Please select accept or reject')
return
}
}
if (requireFeedback) {
if (!feedbackText.trim() || feedbackText.length < feedbackMinLength) {
toast.error(`Please provide feedback (minimum ${feedbackMinLength} characters)`)
return
}
}
// Create evaluation if needed
let evaluationId = existingEvaluation?.id
if (!evaluationId) {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evaluationId = newEval.id
}
// Submit
submitMutation.mutate({
id: evaluationId,
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : {},
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : 5,
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
feedbackText: feedbackText || 'No feedback provided',
})
}
// COI Dialog
if (!coiAccepted && showCOIDialog && evalConfig?.coiRequired !== false) {
return (
<Dialog open={showCOIDialog} onOpenChange={setShowCOIDialog}>
<DialogContent>
@@ -127,6 +263,21 @@ export default function JuryEvaluatePage() {
)
}
if (!round || !project) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<Card>
<CardContent className="py-12">
<div className="flex items-center justify-center">
<Skeleton className="h-64 w-full" />
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
@@ -140,9 +291,7 @@ export default function JuryEvaluatePage() {
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
Evaluate Project
</h1>
<p className="text-muted-foreground mt-1">
{project?.title || 'Loading...'}
</p>
<p className="text-muted-foreground mt-1">{project.title}</p>
</div>
</div>
@@ -163,61 +312,108 @@ export default function JuryEvaluatePage() {
<CardHeader>
<CardTitle>Evaluation Form</CardTitle>
<CardDescription>
Provide your assessment of the project
Provide your assessment using the {scoringMode} scoring method
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="globalScore">
Overall Score <span className="text-destructive">*</span>
</Label>
<Input
id="globalScore"
type="number"
min="1"
max="10"
value={globalScore}
onChange={(e) => setGlobalScore(e.target.value)}
placeholder="Enter score (1-10)"
/>
<p className="text-xs text-muted-foreground">
Provide a score from 1 to 10 based on your overall assessment
</p>
</div>
{/* Criteria-based scoring */}
{scoringMode === 'criteria' && criteria && criteria.length > 0 && (
<div className="space-y-4">
<h3 className="font-semibold">Criteria Scores</h3>
{criteria.map((criterion) => (
<div key={criterion.id} className="space-y-2 p-4 border rounded-lg">
<Label htmlFor={criterion.id}>
{criterion.label}
{evalConfig?.requireAllCriteriaScored !== false && (
<span className="text-destructive ml-1">*</span>
)}
</Label>
{criterion.description && (
<p className="text-xs text-muted-foreground">{criterion.description}</p>
)}
<Input
id={criterion.id}
type="number"
min={criterion.minScore ?? 0}
max={criterion.maxScore ?? 10}
value={criteriaScores[criterion.id] ?? ''}
onChange={(e) =>
setCriteriaScores({
...criteriaScores,
[criterion.id]: parseInt(e.target.value, 10) || 0,
})
}
placeholder={`Score (${criterion.minScore ?? 0}-${criterion.maxScore ?? 10})`}
/>
</div>
))}
</div>
)}
{/* Global scoring */}
{scoringMode === 'global' && (
<div className="space-y-2">
<Label htmlFor="globalScore">
Overall Score <span className="text-destructive">*</span>
</Label>
<Input
id="globalScore"
type="number"
min="1"
max="10"
value={globalScore}
onChange={(e) => setGlobalScore(e.target.value)}
placeholder="Enter score (1-10)"
/>
<p className="text-xs text-muted-foreground">
Provide a score from 1 to 10 based on your overall assessment
</p>
</div>
)}
{/* Binary decision */}
{scoringMode === 'binary' && (
<div className="space-y-2">
<Label>
Decision <span className="text-destructive">*</span>
</Label>
<RadioGroup value={binaryDecision} onValueChange={(v) => setBinaryDecision(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">
<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="feedbackGeneral">
General Feedback <span className="text-destructive">*</span>
<Label htmlFor="feedbackText">
Feedback
{requireFeedback && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id="feedbackGeneral"
value={feedbackGeneral}
onChange={(e) => setFeedbackGeneral(e.target.value)}
placeholder="Provide your overall feedback on the project..."
rows={5}
/>
</div>
<div className="space-y-2">
<Label htmlFor="feedbackStrengths">Strengths</Label>
<Textarea
id="feedbackStrengths"
value={feedbackStrengths}
onChange={(e) => setFeedbackStrengths(e.target.value)}
placeholder="What are the key strengths of this project?"
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="feedbackWeaknesses">Areas for Improvement</Label>
<Textarea
id="feedbackWeaknesses"
value={feedbackWeaknesses}
onChange={(e) => setFeedbackWeaknesses(e.target.value)}
placeholder="What areas could be improved?"
rows={4}
id="feedbackText"
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)}
placeholder="Provide your feedback on the project..."
rows={8}
/>
{requireFeedback && (
<p className="text-xs text-muted-foreground">
Minimum {feedbackMinLength} characters ({feedbackText.length}/{feedbackMinLength})
</p>
)}
</div>
</CardContent>
</Card>
@@ -232,14 +428,15 @@ export default function JuryEvaluatePage() {
<div className="flex gap-3">
<Button
variant="outline"
disabled={submitMutation.isPending}
onClick={handleSaveDraft}
disabled={autosaveMutation.isPending || submitMutation.isPending}
>
<Save className="mr-2 h-4 w-4" />
Save Draft
{autosaveMutation.isPending ? 'Saving...' : 'Save Draft'}
</Button>
<Button
onClick={handleSubmit}
disabled={submitMutation.isPending}
disabled={submitMutation.isPending || autosaveMutation.isPending}
className="bg-brand-blue hover:bg-brand-blue-light"
>
<Send className="mr-2 h-4 w-4" />