Files
MOPC-Portal/src/app/(admin)/admin/rounds/new/page.tsx
Matt 52cdca1b85 Move required reviews field into evaluation settings and add live voting migration
Move the "Required Reviews per Project" field from the Basic Information
card into the Evaluation Settings section of RoundTypeSettings, where it
contextually belongs. Add missing database migration for live voting
enhancements (criteria voting, audience voting, AudienceVoter table).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 16:57:56 +01:00

502 lines
17 KiB
TypeScript

'use client'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
import { ROUND_FIELD_VISIBILITY } from '@/types/round-settings'
import { ArrowLeft, Loader2, AlertCircle, Bell, LayoutTemplate } from 'lucide-react'
import { toast } from 'sonner'
import { DateTimePicker } from '@/components/ui/datetime-picker'
// 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' },
]
const createRoundSchema = z.object({
programId: z.string().min(1, 'Please select a program'),
name: z.string().min(1, 'Name is required').max(255),
requiredReviews: z.number().int().min(0).max(10),
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'],
})
type CreateRoundForm = z.infer<typeof createRoundSchema>
function CreateRoundContent() {
const router = useRouter()
const searchParams = useSearchParams()
const programIdParam = searchParams.get('program')
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
const [entryNotificationType, setEntryNotificationType] = useState<string>('')
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('')
const utils = trpc.useUtils()
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery()
const { data: templates } = trpc.roundTemplate.list.useQuery()
const loadTemplate = (templateId: string) => {
if (!templateId || !templates) return
const template = templates.find((t: { id: string; name: string; roundType: string; settingsJson: unknown }) => t.id === templateId)
if (!template) return
// Apply template settings
const typeMap: Record<string, 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'> = {
EVALUATION: 'EVALUATION',
SELECTION: 'EVALUATION',
FINAL: 'EVALUATION',
LIVE_VOTING: 'LIVE_EVENT',
FILTERING: 'FILTERING',
}
setRoundType(typeMap[template.roundType] || 'EVALUATION')
if (template.settingsJson && typeof template.settingsJson === 'object') {
setRoundSettings(template.settingsJson as Record<string, unknown>)
}
if (template.name) {
form.setValue('name', template.name)
}
setSelectedTemplateId(templateId)
toast.success(`Loaded template: ${template.name}`)
}
const createRound = trpc.round.create.useMutation({
onSuccess: (data) => {
utils.program.list.invalidate({ includeRounds: true })
router.push(`/admin/rounds/${data.id}`)
},
})
const form = useForm<CreateRoundForm>({
resolver: zodResolver(createRoundSchema),
defaultValues: {
programId: programIdParam || '',
name: '',
requiredReviews: 3,
votingStartAt: null,
votingEndAt: null,
},
})
const onSubmit = async (data: CreateRoundForm) => {
const visibility = ROUND_FIELD_VISIBILITY[roundType]
await createRound.mutateAsync({
programId: data.programId,
name: data.name,
roundType,
requiredReviews: visibility?.showRequiredReviews ? data.requiredReviews : 0,
settingsJson: roundSettings,
votingStartAt: visibility?.showVotingWindow ? (data.votingStartAt ?? undefined) : undefined,
votingEndAt: visibility?.showVotingWindow ? (data.votingEndAt ?? undefined) : undefined,
entryNotificationType: entryNotificationType || undefined,
})
}
if (loadingPrograms) {
return <CreateRoundSkeleton />
}
if (!programs || programs.length === 0) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/rounds">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Rounds
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Programs Found</p>
<p className="text-sm text-muted-foreground">
Create a program first before creating rounds
</p>
<Button asChild className="mt-4">
<Link href="/admin/programs/new">Create Program</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/rounds">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Rounds
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Create Round</h1>
<p className="text-muted-foreground">
Set up a new selection round for project evaluation
</p>
</div>
{/* Template Selector */}
{templates && templates.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<LayoutTemplate className="h-5 w-5" />
Start from Template
</CardTitle>
<CardDescription>
Load settings from a saved template to get started quickly
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<Select
value={selectedTemplateId}
onValueChange={loadTemplate}
>
<SelectTrigger className="w-full max-w-sm">
<SelectValue placeholder="Select a template..." />
</SelectTrigger>
<SelectContent>
{templates.map((t: { id: string; name: string; description?: string | null }) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
{t.description ? ` - ${t.description}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedTemplateId && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedTemplateId('')
setRoundType('EVALUATION')
setRoundSettings({})
form.reset()
toast.info('Template cleared')
}}
>
Clear
</Button>
)}
</div>
</CardContent>
</Card>
)}
{/* Form */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">Basic Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="programId"
render={({ field }) => (
<FormItem>
<FormLabel>Edition</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an edition" />
</SelectTrigger>
</FormControl>
<SelectContent>
{programs.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.year} Edition
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Round Name</FormLabel>
<FormControl>
<Input
placeholder="e.g., Round 1 - Semi-Finalists"
{...field}
/>
</FormControl>
<FormDescription>
A descriptive name for this selection round
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Round Type & Settings */}
<RoundTypeSettings
roundType={roundType}
onRoundTypeChange={setRoundType}
settings={roundSettings}
onSettingsChange={setRoundSettings}
requiredReviewsField={
<FormField
control={form.control}
name="requiredReviews"
render={({ field }) => (
<FormItem>
<FormLabel>Required Reviews per Project</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={10}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
/>
</FormControl>
<FormDescription>
Minimum number of evaluations each project should receive
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
}
/>
{ROUND_FIELD_VISIBILITY[roundType]?.showVotingWindow && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Voting Window</CardTitle>
<CardDescription>
Optional: Set when jury members can submit their evaluations
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="votingStartAt"
render={({ field }) => (
<FormItem>
<FormLabel>Start Date & Time</FormLabel>
<FormControl>
<DateTimePicker
value={field.value}
onChange={field.onChange}
placeholder="Select start date & time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="votingEndAt"
render={({ field }) => (
<FormItem>
<FormLabel>End Date & Time</FormLabel>
<FormControl>
<DateTimePicker
value={field.value}
onChange={field.onChange}
placeholder="Select end date & time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<p className="text-sm text-muted-foreground">
Leave empty to set the voting window later. Past dates are allowed.
</p>
</CardContent>
</Card>
)}
{/* Team Notification */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Bell className="h-5 w-5" />
Team Notification
</CardTitle>
<CardDescription>
Notification sent to project teams when they enter this round
</CardDescription>
</CardHeader>
<CardContent>
<Select
value={entryNotificationType || 'none'}
onValueChange={(val) => setEntryNotificationType(val === 'none' ? '' : val)}
>
<SelectTrigger>
<SelectValue placeholder="No automatic notification" />
</SelectTrigger>
<SelectContent>
{TEAM_NOTIFICATION_OPTIONS.map((option) => (
<SelectItem key={option.value || 'none'} value={option.value || 'none'}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground mt-2">
When projects advance to this round, the selected notification will be sent to the project team automatically.
</p>
</CardContent>
</Card>
{/* Error */}
{createRound.error && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 py-4">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">
{(() => {
const msg = createRound.error.message
try {
const parsed = JSON.parse(msg)
if (Array.isArray(parsed)) {
return parsed.map((e: { message?: string; path?: string[] }) =>
e.message || 'Validation error'
).join('. ')
}
} catch {
// Not JSON, use as-is
}
return msg
})()}
</p>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" asChild>
<Link href="/admin/rounds">Cancel</Link>
</Button>
<Button type="submit" disabled={createRound.isPending}>
{createRound.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Round
</Button>
</div>
</form>
</Form>
</div>
)
}
function CreateRoundSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-1">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-10 w-32" />
</div>
</CardContent>
</Card>
</div>
)
}
export default function CreateRoundPage() {
return (
<Suspense fallback={<CreateRoundSkeleton />}>
<CreateRoundContent />
</Suspense>
)
}