'use client' import { Suspense, use, useState, useEffect } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { trpc } from '@/lib/trpc/client' import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { Badge } from '@/components/ui/badge' import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form' import { EvaluationFormBuilder, type Criterion, } from '@/components/forms/evaluation-form-builder' import { RoundTypeSettings } from '@/components/forms/round-type-settings' import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar } from 'lucide-react' import { Switch } from '@/components/ui/switch' import { Slider } from '@/components/ui/slider' import { Label } from '@/components/ui/label' import { DateTimePicker } from '@/components/ui/datetime-picker' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' // Available notification types for teams entering a round const TEAM_NOTIFICATION_OPTIONS = [ { value: '', label: 'No automatic notification', description: 'Teams will not receive a notification when entering this round' }, { value: 'ADVANCED_SEMIFINAL', label: 'Advanced to Semi-Finals', description: 'Congratulates team for advancing to semi-finals' }, { value: 'ADVANCED_FINAL', label: 'Selected as Finalist', description: 'Congratulates team for being selected as finalist' }, { value: 'NOT_SELECTED', label: 'Not Selected', description: 'Informs team they were not selected to continue' }, { value: 'WINNER_ANNOUNCEMENT', label: 'Winner Announcement', description: 'Announces the team as a winner' }, ] interface PageProps { params: Promise<{ id: string }> } const updateRoundSchema = z .object({ name: z.string().min(1, 'Name is required').max(255), requiredReviews: z.number().int().min(1).max(10), minAssignmentsPerJuror: z.number().int().min(1).max(50), maxAssignmentsPerJuror: z.number().int().min(1).max(100), votingStartAt: z.date().nullable().optional(), votingEndAt: z.date().nullable().optional(), }) .refine( (data) => { if (data.votingStartAt && data.votingEndAt) { return data.votingEndAt > data.votingStartAt } return true }, { message: 'End date must be after start date', path: ['votingEndAt'], } ) .refine( (data) => data.minAssignmentsPerJuror <= data.maxAssignmentsPerJuror, { message: 'Min must be less than or equal to max', path: ['minAssignmentsPerJuror'], } ) type UpdateRoundForm = z.infer function EditRoundContent({ roundId }: { roundId: string }) { const router = useRouter() const [criteria, setCriteria] = useState([]) const [criteriaInitialized, setCriteriaInitialized] = useState(false) const [formInitialized, setFormInitialized] = useState(false) const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION') const [roundSettings, setRoundSettings] = useState>({}) // entryNotificationType removed from schema // Fetch round data - disable refetch on focus to prevent overwriting user's edits const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery( { id: roundId }, { refetchOnWindowFocus: false } ) // Fetch evaluation form const { data: evaluationForm, isLoading: loadingForm } = trpc.round.getEvaluationForm.useQuery({ roundId }) // Check if evaluations exist const { data: hasEvaluations } = trpc.round.hasEvaluations.useQuery({ roundId, }) const utils = trpc.useUtils() // Mutations const updateRound = trpc.round.update.useMutation({ onSuccess: () => { // Invalidate cache to ensure fresh data utils.round.get.invalidate({ id: roundId }) utils.round.list.invalidate() router.push(`/admin/rounds/${roundId}`) }, }) const updateEvaluationForm = trpc.round.updateEvaluationForm.useMutation() // Initialize form with existing data const form = useForm({ resolver: zodResolver(updateRoundSchema), defaultValues: { name: '', requiredReviews: 3, minAssignmentsPerJuror: 5, maxAssignmentsPerJuror: 20, votingStartAt: null, votingEndAt: null, }, }) // Update form when round data loads - only initialize once useEffect(() => { if (round && !formInitialized) { form.reset({ name: round.name, requiredReviews: round.requiredReviews, minAssignmentsPerJuror: round.minAssignmentsPerJuror, maxAssignmentsPerJuror: round.maxAssignmentsPerJuror, votingStartAt: round.votingStartAt ? new Date(round.votingStartAt) : null, votingEndAt: round.votingEndAt ? new Date(round.votingEndAt) : null, }) // Set round type, settings, and notification type setRoundType((round.roundType as typeof roundType) || 'EVALUATION') setRoundSettings((round.settingsJson as Record) || {}) setFormInitialized(true) } }, [round, form, formInitialized]) // Initialize criteria from evaluation form useEffect(() => { if (evaluationForm && !criteriaInitialized) { const existingCriteria = evaluationForm.criteriaJson as unknown as Criterion[] if (Array.isArray(existingCriteria)) { setCriteria(existingCriteria) } setCriteriaInitialized(true) } else if (!loadingForm && !evaluationForm && !criteriaInitialized) { setCriteriaInitialized(true) } }, [evaluationForm, loadingForm, criteriaInitialized]) const onSubmit = async (data: UpdateRoundForm) => { // Update round with type, settings, and notification await updateRound.mutateAsync({ id: roundId, name: data.name, requiredReviews: data.requiredReviews, minAssignmentsPerJuror: data.minAssignmentsPerJuror, maxAssignmentsPerJuror: data.maxAssignmentsPerJuror, roundType, settingsJson: roundSettings, votingStartAt: data.votingStartAt ?? null, votingEndAt: data.votingEndAt ?? null, }) // Update evaluation form if criteria changed and no evaluations exist if (!hasEvaluations && criteria.length > 0) { await updateEvaluationForm.mutateAsync({ roundId, criteria, }) } } const isLoading = loadingRound || loadingForm if (isLoading) { return } if (!round) { return (

Round Not Found

) } const isPending = updateRound.isPending || updateEvaluationForm.isPending const isActive = round.status === 'ACTIVE' return (
{/* Header */}

Edit Round

{round.status}
{/* Form */}
{/* Basic Information */} Basic Information ( Round Name )} /> ( Required Reviews per Project field.onChange(parseInt(e.target.value) || 1) } /> Minimum number of evaluations each project should receive )} />
( Min Projects per Judge field.onChange(parseInt(e.target.value) || 1) } /> Target minimum projects each judge should receive )} /> ( Max Projects per Judge field.onChange(parseInt(e.target.value) || 1) } /> Maximum projects a judge can be assigned )} />
{/* Round Type & Settings */} {/* Voting Window */} Voting Window Set when jury members can submit their evaluations {isActive && (

This round is active. Changing the voting window may affect ongoing evaluations.

)}
( Start Date & Time )} /> ( End Date & Time )} />

Leave empty to disable the voting window enforcement. Past dates are allowed.

{/* Upload Deadline Policy */} Upload Deadline Policy Control how file uploads are handled after the round starts

When set to “Block”, applicants cannot upload files after the voting start date. When set to “Allow late”, uploads are accepted but flagged as late submissions.

{/* Jury Features */} Jury Features Configure project comparison and peer review for jury members {/* Comparison settings */}

Allow jury members to compare projects side by side

setRoundSettings((prev) => ({ ...prev, enable_comparison: checked, })) } />
{!!roundSettings.enable_comparison && (
setRoundSettings((prev) => ({ ...prev, comparison_max_projects: parseInt(e.target.value) || 3, })) } className="max-w-[120px]" />
)}
{/* Peer review settings */}

Allow jury members to discuss and see aggregated scores

setRoundSettings((prev) => ({ ...prev, peer_review_enabled: checked, })) } />
{!!roundSettings.peer_review_enabled && (

Score divergence level that triggers a warning (0.0 - 1.0)

setRoundSettings((prev) => ({ ...prev, divergence_threshold: parseFloat(e.target.value) || 0.3, })) } className="max-w-[120px]" />
setRoundSettings((prev) => ({ ...prev, discussion_window_hours: parseInt(e.target.value) || 48, })) } className="max-w-[120px]" />
setRoundSettings((prev) => ({ ...prev, max_comment_length: parseInt(e.target.value) || 2000, })) } className="max-w-[120px]" />
)}
{/* File Settings */} File Settings Configure allowed file types and versioning for this round

Comma-separated MIME types or extensions

setRoundSettings((prev) => ({ ...prev, allowed_file_types: e.target.value, })) } />
setRoundSettings((prev) => ({ ...prev, max_file_size_mb: parseInt(e.target.value) || 500, })) } className="max-w-[150px]" />

Keep previous versions when files are replaced

setRoundSettings((prev) => ({ ...prev, file_versioning: checked, })) } />
{!!roundSettings.file_versioning && (
setRoundSettings((prev) => ({ ...prev, max_file_versions: parseInt(e.target.value) || 5, })) } className="max-w-[120px]" />
)}
{/* Availability Settings */} Jury Availability Settings Configure how jury member availability affects assignments

Jury members must set availability before receiving assignments

setRoundSettings((prev) => ({ ...prev, require_availability: checked, })) } />

How much weight to give availability when using soft penalty mode

setRoundSettings((prev) => ({ ...prev, availability_weight: value, })) } className="max-w-xs" />
{/* Evaluation Criteria */} Evaluation Criteria Define the criteria jurors will use to evaluate projects {hasEvaluations ? (

Criteria cannot be modified after evaluations have been submitted. {criteria.length} criteria defined.

{}} disabled={true} />
) : ( )}
{/* Error Display */} {(updateRound.error || updateEvaluationForm.error) && (

{updateRound.error?.message || updateEvaluationForm.error?.message}

)} {/* Actions */}
) } function EditRoundSkeleton() { return (
) } export default function EditRoundPage({ params }: PageProps) { const { id } = use(params) return ( }> ) }