diff --git a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx index e107774..0bac1bd 100644 --- a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx @@ -1,23 +1,23 @@ -'use client' +"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 { 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' +} 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, @@ -26,15 +26,26 @@ import { FormItem, FormLabel, FormMessage, -} from '@/components/ui/form' +} from "@/components/ui/form"; import { EvaluationFormBuilder, type Criterion, -} from '@/components/forms/evaluation-form-builder' -import { RoundTypeSettings } from '@/components/forms/round-type-settings' -import { ROUND_FIELD_VISIBILITY } from '@/types/round-settings' -import { FileRequirementsEditor } from '@/components/admin/file-requirements-editor' -import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar, LayoutTemplate } from 'lucide-react' +} from "@/components/forms/evaluation-form-builder"; +import { RoundTypeSettings } from "@/components/forms/round-type-settings"; +import { ROUND_FIELD_VISIBILITY } from "@/types/round-settings"; +import { FileRequirementsEditor } from "@/components/admin/file-requirements-editor"; +import { + ArrowLeft, + Loader2, + AlertCircle, + AlertTriangle, + Bell, + GitCompare, + MessageSquare, + FileText, + Calendar, + LayoutTemplate, +} from "lucide-react"; import { Dialog, DialogContent, @@ -43,37 +54,86 @@ import { DialogHeader, DialogTitle, DialogTrigger, -} from '@/components/ui/dialog' -import { toast } from 'sonner' -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' +} from "@/components/ui/dialog"; +import { toast } from "sonner"; +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' +} 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' }, - { value: 'SUBMISSION_RECEIVED', label: 'Submission Received', description: 'Confirms to the team that their submission has been received' }, -] + { + 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", + }, + { + value: "SUBMISSION_RECEIVED", + label: "Submission Received", + description: "Confirms to the team that their submission has been received", + }, +]; + +const FILE_TYPE_PRESETS = [ + { value: "any", label: "Any file type", settingValue: "" }, + { value: "pdf", label: "PDF only", settingValue: "application/pdf" }, + { value: "images", label: "Images only", settingValue: "image/*" }, + { value: "videos", label: "Videos only", settingValue: "video/*" }, + { + value: "docs", + label: "Documents (PDF, Word, Excel, PowerPoint)", + settingValue: + "application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.presentationml.presentation", + }, + { + value: "media", + label: "Media (images + videos)", + settingValue: "image/*,video/*", + }, + { + value: "all_standard", + label: "Documents + Media", + settingValue: + "application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.presentationml.presentation,image/*,video/*", + }, +]; interface PageProps { - params: Promise<{ id: string }> + params: Promise<{ id: string }>; } const updateRoundSchema = z .object({ - name: z.string().min(1, 'Name is required').max(255), + name: z.string().min(1, "Name is required").max(255), requiredReviews: z.number().int().min(0).max(10), minAssignmentsPerJuror: z.number().int().min(1).max(50), maxAssignmentsPerJuror: z.number().int().min(1).max(100), @@ -83,94 +143,98 @@ const updateRoundSchema = z .refine( (data) => { if (data.votingStartAt && data.votingEndAt) { - return data.votingEndAt > data.votingStartAt + return data.votingEndAt > data.votingStartAt; } - return true + return true; }, { - message: 'End date must be after start date', - path: ['votingEndAt'], - } + 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'], - } - ) + message: "Min must be less than or equal to max", + path: ["minAssignmentsPerJuror"], + }, + ); -type UpdateRoundForm = z.infer +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>({}) + 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 } - ) + { refetchOnWindowFocus: false }, + ); // Fetch evaluation form const { data: evaluationForm, isLoading: loadingForm } = - trpc.round.getEvaluationForm.useQuery({ roundId }) + trpc.round.getEvaluationForm.useQuery({ roundId }); // Check if evaluations exist const { data: hasEvaluations } = trpc.round.hasEvaluations.useQuery({ roundId, - }) + }); - const [saveTemplateOpen, setSaveTemplateOpen] = useState(false) - const [templateName, setTemplateName] = useState('') + const [saveTemplateOpen, setSaveTemplateOpen] = useState(false); + const [templateName, setTemplateName] = useState(""); - const utils = trpc.useUtils() + const utils = trpc.useUtils(); // Mutations const saveAsTemplate = trpc.roundTemplate.create.useMutation({ onSuccess: () => { - utils.roundTemplate.list.invalidate() - toast.success('Round saved as template') - setSaveTemplateOpen(false) - setTemplateName('') + utils.roundTemplate.list.invalidate(); + toast.success("Round saved as template"); + setSaveTemplateOpen(false); + setTemplateName(""); }, onError: (error) => { - toast.error(error.message) + toast.error(error.message); }, - }) + }); const updateRound = trpc.round.update.useMutation({ onSuccess: () => { - utils.round.get.invalidate({ id: roundId }) - utils.round.list.invalidate() - utils.program.list.invalidate({ includeRounds: true }) - router.push(`/admin/rounds/${roundId}`) + utils.round.get.invalidate({ id: roundId }); + utils.round.list.invalidate(); + utils.program.list.invalidate({ includeRounds: true }); + router.push(`/admin/rounds/${roundId}`); }, - }) + }); const updateEvaluationForm = trpc.round.updateEvaluationForm.useMutation({ onSuccess: () => { - utils.round.get.invalidate({ id: roundId }) + utils.round.get.invalidate({ id: roundId }); }, - }) + }); // Initialize form with existing data const form = useForm({ resolver: zodResolver(updateRoundSchema), defaultValues: { - name: '', + name: "", requiredReviews: 3, minAssignmentsPerJuror: 5, maxAssignmentsPerJuror: 20, votingStartAt: null, votingEndAt: null, }, - }) + }); // Update form when round data loads - only initialize once useEffect(() => { @@ -180,57 +244,66 @@ function EditRoundContent({ roundId }: { roundId: string }) { requiredReviews: round.requiredReviews, minAssignmentsPerJuror: round.minAssignmentsPerJuror, maxAssignmentsPerJuror: round.maxAssignmentsPerJuror, - votingStartAt: round.votingStartAt ? new Date(round.votingStartAt) : null, + 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) + setRoundType((round.roundType as typeof roundType) || "EVALUATION"); + setRoundSettings((round.settingsJson as Record) || {}); + setFormInitialized(true); } - }, [round, form, formInitialized]) + }, [round, form, formInitialized]); // Initialize criteria from evaluation form useEffect(() => { if (evaluationForm && !criteriaInitialized) { - const existingCriteria = evaluationForm.criteriaJson as unknown as Criterion[] + const existingCriteria = + evaluationForm.criteriaJson as unknown as Criterion[]; if (Array.isArray(existingCriteria)) { - setCriteria(existingCriteria) + setCriteria(existingCriteria); } - setCriteriaInitialized(true) + setCriteriaInitialized(true); } else if (!loadingForm && !evaluationForm && !criteriaInitialized) { - setCriteriaInitialized(true) + setCriteriaInitialized(true); } - }, [evaluationForm, loadingForm, criteriaInitialized]) + }, [evaluationForm, loadingForm, criteriaInitialized]); const onSubmit = async (data: UpdateRoundForm) => { - const visibility = ROUND_FIELD_VISIBILITY[roundType] + const visibility = ROUND_FIELD_VISIBILITY[roundType]; // Update round with type, settings, and notification await updateRound.mutateAsync({ id: roundId, name: data.name, - requiredReviews: visibility?.showRequiredReviews ? data.requiredReviews : 0, + requiredReviews: visibility?.showRequiredReviews + ? data.requiredReviews + : 0, minAssignmentsPerJuror: data.minAssignmentsPerJuror, maxAssignmentsPerJuror: data.maxAssignmentsPerJuror, roundType, settingsJson: roundSettings, - votingStartAt: visibility?.showVotingWindow ? (data.votingStartAt ?? null) : null, - votingEndAt: visibility?.showVotingWindow ? (data.votingEndAt ?? null) : null, - }) + votingStartAt: visibility?.showVotingWindow + ? (data.votingStartAt ?? null) + : null, + votingEndAt: visibility?.showVotingWindow + ? (data.votingEndAt ?? null) + : 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 + const isLoading = loadingRound || loadingForm; if (isLoading) { - return + return ; } if (!round) { @@ -253,11 +326,11 @@ function EditRoundContent({ roundId }: { roundId: string }) { - ) + ); } - const isPending = updateRound.isPending || updateEvaluationForm.isPending - const isActive = round.status === 'ACTIVE' + const isPending = updateRound.isPending || updateEvaluationForm.isPending; + const isActive = round.status === "ACTIVE"; return (
@@ -273,7 +346,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {

Edit Round

- + {round.status}
@@ -305,57 +378,57 @@ function EditRoundContent({ roundId }: { roundId: string }) { /> {ROUND_FIELD_VISIBILITY[roundType]?.showAssignmentLimits && ( -
- ( - - Min Projects per Judge - - - field.onChange(parseInt(e.target.value) || 1) - } - /> - - - Target minimum projects each judge 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 - - - - )} - /> -
+ ( + + Max Projects per Judge + + + field.onChange(parseInt(e.target.value) || 1) + } + /> + + + Maximum projects a judge can be assigned + + + + )} + /> +
)} @@ -452,7 +525,8 @@ function EditRoundContent({ roundId }: { roundId: string }) {

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

@@ -467,7 +541,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {

- 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. + 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.

@@ -516,7 +590,9 @@ function EditRoundContent({ roundId }: { roundId: string }) {
- +

Allow jury members to compare projects side by side

@@ -542,7 +618,8 @@ function EditRoundContent({ roundId }: { roundId: string }) { onChange={(e) => setRoundSettings((prev) => ({ ...prev, - comparison_max_projects: parseInt(e.target.value) || 3, + comparison_max_projects: + parseInt(e.target.value) || 3, })) } className="max-w-[120px]" @@ -578,18 +655,22 @@ function EditRoundContent({ roundId }: { roundId: string }) {

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

setRoundSettings((prev) => ({ ...prev, - divergence_threshold: parseFloat(e.target.value) || 0.3, + divergence_threshold: + parseFloat(e.target.value) || 0.3, })) } className="max-w-[120px]" @@ -598,7 +679,9 @@ function EditRoundContent({ roundId }: { roundId: string }) {
- + setRoundSettings((prev) => ({ ...prev, - discussion_window_hours: parseInt(e.target.value) || 48, + discussion_window_hours: + parseInt(e.target.value) || 48, })) } className="max-w-[120px]" @@ -642,7 +734,8 @@ function EditRoundContent({ roundId }: { roundId: string }) { onChange={(e) => setRoundSettings((prev) => ({ ...prev, - max_comment_length: parseInt(e.target.value) || 2000, + max_comment_length: + parseInt(e.target.value) || 2000, })) } className="max-w-[120px]" @@ -668,19 +761,35 @@ function EditRoundContent({ roundId }: { roundId: string }) {
-

- Comma-separated MIME types or extensions -

- +
@@ -700,7 +809,9 @@ function EditRoundContent({ roundId }: { roundId: string }) {
- +

Keep previous versions when files are replaced

@@ -750,9 +861,12 @@ function EditRoundContent({ roundId }: { roundId: string }) {
- +

- Jury members must set availability before receiving assignments + Jury members must set availability before receiving + assignments

+ {selectedTemplateId && ( +
+ + + {draftRequirements.length === 0 ? ( +

+ No file requirements added yet. +

+ ) : ( +
+ {draftRequirements.map((req) => ( +
+
+
+
+

{req.name}

+ + {req.isRequired ? "Required" : "Optional"} + +
+ {req.description && ( +

+ {req.description} +

+ )} +
+ {req.acceptedMimeTypes.map((mime) => ( + + {mime} + + ))} + {req.maxSizeMB && ( + + Max {req.maxSizeMB}MB + + )} +
+
+
+ + +
+
+
+ ))} +
+ )} +
+ + + {/* Jury Features */} + + + + + Jury Features + + + Configure project comparison and peer review for jury members + + + +
+
+
+ +

+ 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, 10) || 3, + })) + } + className="max-w-[120px]" + /> +
+ )} +
+ +
+
+
+ +

+ Allow jury members to discuss and see aggregated scores +

+
+ + setRoundSettings((prev) => ({ + ...prev, + peer_review_enabled: checked, + })) + } + /> +
+ {!!roundSettings.peer_review_enabled && ( +
+
+ + + 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, 10) || 48, + })) + } + className="max-w-[120px]" + /> +
+
+ + + setRoundSettings((prev) => ({ + ...prev, + max_comment_length: + parseInt(e.target.value, 10) || 2000, + })) + } + className="max-w-[120px]" + /> +
+
+ )} +
+
+
+ + {/* File Settings */} + + + + + File Settings + + + Configure allowed file types and versioning for this round + + + +
+ + +
+
+ + + setRoundSettings((prev) => ({ + ...prev, + max_file_size_mb: parseInt(e.target.value, 10) || 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, 10) || 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, + })) + } + /> +
+ +
+ + +
+ +
+ + + setRoundSettings((prev) => ({ + ...prev, + availability_weight: value, + })) + } + className="max-w-xs" + /> +
+
+
+ + {ROUND_FIELD_VISIBILITY[roundType]?.showEvaluationForm && ( + + + Evaluation Criteria + + Define the criteria jurors will use to evaluate projects + + + + + + )} {/* Team Notification */} @@ -396,26 +1349,155 @@ function CreateRoundContent() {

- When projects advance to this round, the selected notification will be sent to the project team automatically. + When projects advance to this round, the selected notification + will be sent to the project team automatically.

+ + + + + {editingRequirementId ? "Edit" : "Add"} File Requirement + + + Define what file applicants need to upload for this round. + + + +
+
+ + + setRequirementForm((prev) => ({ + ...prev, + name: e.target.value, + })) + } + placeholder="e.g., Executive Summary" + /> +
+ +
+ +