Files
MOPC-Portal/src/app/(admin)/admin/rounds/new/page.tsx
Matt 7b85fd9602 Auto-assign projects to first round, auto-filter on close, pipeline UX consolidation
- New projects (admin create, CSV import, public form) auto-assign to program's
  first round (by sortOrder) when no round is specified
- Closing a FILTERING round auto-starts filtering job (configurable via
  autoFilterOnClose setting, defaults to true)
- Add SUBMISSION_RECEIVED notification type for confirming submissions
- Replace separate List/Pipeline toggle with integrated pipeline view below
  the sortable round list
- Add autoFilterOnClose toggle to filtering round type settings UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:06:11 +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>
)}
/>
{ROUND_FIELD_VISIBILITY[roundType]?.showRequiredReviews && (
<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>
)}
/>
)}
</CardContent>
</Card>
{/* Round Type & Settings */}
<RoundTypeSettings
roundType={roundType}
onRoundTypeChange={setRoundType}
settings={roundSettings}
onSettingsChange={setRoundSettings}
/>
{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>
)
}