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
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:
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user