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>
502 lines
17 KiB
TypeScript
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>
|
|
)
|
|
}
|