308 lines
8.4 KiB
TypeScript
308 lines
8.4 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import { useState, useEffect } from 'react'
|
||
|
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||
|
|
import Link from 'next/link'
|
||
|
|
import type { Route } from 'next'
|
||
|
|
import { trpc } from '@/lib/trpc/client'
|
||
|
|
import { toast } from 'sonner'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
|
||
|
|
import type { StepConfig } from '@/components/ui/sidebar-stepper'
|
||
|
|
import { ArrowLeft } from 'lucide-react'
|
||
|
|
import { BasicsSection } from '@/components/admin/competition/sections/basics-section'
|
||
|
|
import { RoundsSection } from '@/components/admin/competition/sections/rounds-section'
|
||
|
|
import { JuryGroupsSection } from '@/components/admin/competition/sections/jury-groups-section'
|
||
|
|
import { ReviewSection } from '@/components/admin/competition/sections/review-section'
|
||
|
|
import { useEdition } from '@/contexts/edition-context'
|
||
|
|
|
||
|
|
type WizardRound = {
|
||
|
|
tempId: string
|
||
|
|
name: string
|
||
|
|
slug: string
|
||
|
|
roundType: string
|
||
|
|
sortOrder: number
|
||
|
|
configJson: Record<string, unknown>
|
||
|
|
}
|
||
|
|
|
||
|
|
type WizardJuryGroup = {
|
||
|
|
tempId: string
|
||
|
|
name: string
|
||
|
|
slug: string
|
||
|
|
defaultMaxAssignments: number
|
||
|
|
defaultCapMode: string
|
||
|
|
sortOrder: number
|
||
|
|
}
|
||
|
|
|
||
|
|
type WizardState = {
|
||
|
|
programId: string
|
||
|
|
name: string
|
||
|
|
slug: string
|
||
|
|
categoryMode: string
|
||
|
|
startupFinalistCount: number
|
||
|
|
conceptFinalistCount: number
|
||
|
|
notifyOnRoundAdvance: boolean
|
||
|
|
notifyOnDeadlineApproach: boolean
|
||
|
|
deadlineReminderDays: number[]
|
||
|
|
rounds: WizardRound[]
|
||
|
|
juryGroups: WizardJuryGroup[]
|
||
|
|
}
|
||
|
|
|
||
|
|
const defaultRounds: WizardRound[] = [
|
||
|
|
{
|
||
|
|
tempId: crypto.randomUUID(),
|
||
|
|
name: 'Intake',
|
||
|
|
slug: 'intake',
|
||
|
|
roundType: 'INTAKE',
|
||
|
|
sortOrder: 0,
|
||
|
|
configJson: {},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
tempId: crypto.randomUUID(),
|
||
|
|
name: 'Filtering',
|
||
|
|
slug: 'filtering',
|
||
|
|
roundType: 'FILTERING',
|
||
|
|
sortOrder: 1,
|
||
|
|
configJson: {},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
tempId: crypto.randomUUID(),
|
||
|
|
name: 'Evaluation (Jury 1)',
|
||
|
|
slug: 'evaluation-jury-1',
|
||
|
|
roundType: 'EVALUATION',
|
||
|
|
sortOrder: 2,
|
||
|
|
configJson: {},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
tempId: crypto.randomUUID(),
|
||
|
|
name: 'Submission',
|
||
|
|
slug: 'submission',
|
||
|
|
roundType: 'SUBMISSION',
|
||
|
|
sortOrder: 3,
|
||
|
|
configJson: {},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
tempId: crypto.randomUUID(),
|
||
|
|
name: 'Evaluation (Jury 2)',
|
||
|
|
slug: 'evaluation-jury-2',
|
||
|
|
roundType: 'EVALUATION',
|
||
|
|
sortOrder: 4,
|
||
|
|
configJson: {},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
tempId: crypto.randomUUID(),
|
||
|
|
name: 'Mentoring',
|
||
|
|
slug: 'mentoring',
|
||
|
|
roundType: 'MENTORING',
|
||
|
|
sortOrder: 5,
|
||
|
|
configJson: {},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
tempId: crypto.randomUUID(),
|
||
|
|
name: 'Live Final',
|
||
|
|
slug: 'live-final',
|
||
|
|
roundType: 'LIVE_FINAL',
|
||
|
|
sortOrder: 6,
|
||
|
|
configJson: {},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
tempId: crypto.randomUUID(),
|
||
|
|
name: 'Deliberation',
|
||
|
|
slug: 'deliberation',
|
||
|
|
roundType: 'DELIBERATION',
|
||
|
|
sortOrder: 7,
|
||
|
|
configJson: {},
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
export default function NewCompetitionPage() {
|
||
|
|
const router = useRouter()
|
||
|
|
const searchParams = useSearchParams()
|
||
|
|
const { currentEdition } = useEdition()
|
||
|
|
const paramProgramId = searchParams.get('programId')
|
||
|
|
const programId = paramProgramId || currentEdition?.id || ''
|
||
|
|
|
||
|
|
const [currentStep, setCurrentStep] = useState(0)
|
||
|
|
const [isDirty, setIsDirty] = useState(false)
|
||
|
|
|
||
|
|
const [state, setState] = useState<WizardState>({
|
||
|
|
programId,
|
||
|
|
name: '',
|
||
|
|
slug: '',
|
||
|
|
categoryMode: 'SHARED',
|
||
|
|
startupFinalistCount: 3,
|
||
|
|
conceptFinalistCount: 3,
|
||
|
|
notifyOnRoundAdvance: true,
|
||
|
|
notifyOnDeadlineApproach: true,
|
||
|
|
deadlineReminderDays: [7, 3, 1],
|
||
|
|
rounds: defaultRounds,
|
||
|
|
juryGroups: [],
|
||
|
|
})
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (programId) {
|
||
|
|
setState((prev) => ({ ...prev, programId }))
|
||
|
|
}
|
||
|
|
}, [programId])
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||
|
|
if (isDirty) {
|
||
|
|
e.preventDefault()
|
||
|
|
e.returnValue = ''
|
||
|
|
}
|
||
|
|
}
|
||
|
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||
|
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||
|
|
}, [isDirty])
|
||
|
|
|
||
|
|
const utils = trpc.useUtils()
|
||
|
|
const createCompetitionMutation = trpc.competition.create.useMutation()
|
||
|
|
const createRoundMutation = trpc.round.create.useMutation()
|
||
|
|
const createJuryGroupMutation = trpc.juryGroup.create.useMutation()
|
||
|
|
|
||
|
|
const handleStateChange = (updates: Partial<WizardState>) => {
|
||
|
|
setState((prev) => ({ ...prev, ...updates }))
|
||
|
|
setIsDirty(true)
|
||
|
|
|
||
|
|
// Auto-generate slug from name if name changed
|
||
|
|
if (updates.name !== undefined && updates.slug === undefined) {
|
||
|
|
const autoSlug = updates.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||
|
|
setState((prev) => ({ ...prev, slug: autoSlug }))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleSubmit = async () => {
|
||
|
|
if (!state.name.trim()) {
|
||
|
|
toast.error('Competition name is required')
|
||
|
|
setCurrentStep(0)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!state.slug.trim()) {
|
||
|
|
toast.error('Competition slug is required')
|
||
|
|
setCurrentStep(0)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (state.rounds.length === 0) {
|
||
|
|
toast.error('At least one round is required')
|
||
|
|
setCurrentStep(1)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Create competition
|
||
|
|
const competition = await createCompetitionMutation.mutateAsync({
|
||
|
|
programId: state.programId,
|
||
|
|
name: state.name,
|
||
|
|
slug: state.slug,
|
||
|
|
categoryMode: state.categoryMode,
|
||
|
|
startupFinalistCount: state.startupFinalistCount,
|
||
|
|
conceptFinalistCount: state.conceptFinalistCount,
|
||
|
|
notifyOnRoundAdvance: state.notifyOnRoundAdvance,
|
||
|
|
notifyOnDeadlineApproach: state.notifyOnDeadlineApproach,
|
||
|
|
deadlineReminderDays: state.deadlineReminderDays,
|
||
|
|
})
|
||
|
|
|
||
|
|
// Create rounds
|
||
|
|
for (const round of state.rounds) {
|
||
|
|
await createRoundMutation.mutateAsync({
|
||
|
|
competitionId: competition.id,
|
||
|
|
name: round.name,
|
||
|
|
slug: round.slug,
|
||
|
|
roundType: round.roundType as any,
|
||
|
|
sortOrder: round.sortOrder,
|
||
|
|
configJson: round.configJson,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create jury groups
|
||
|
|
for (const group of state.juryGroups) {
|
||
|
|
await createJuryGroupMutation.mutateAsync({
|
||
|
|
competitionId: competition.id,
|
||
|
|
name: group.name,
|
||
|
|
slug: group.slug,
|
||
|
|
defaultMaxAssignments: group.defaultMaxAssignments,
|
||
|
|
defaultCapMode: group.defaultCapMode as any,
|
||
|
|
sortOrder: group.sortOrder,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
toast.success('Competition created successfully')
|
||
|
|
setIsDirty(false)
|
||
|
|
utils.competition.list.invalidate()
|
||
|
|
router.push(`/admin/competitions/${competition.id}` as Route)
|
||
|
|
} catch (err: any) {
|
||
|
|
toast.error(err.message || 'Failed to create competition')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const steps: StepConfig[] = [
|
||
|
|
{
|
||
|
|
title: 'Basics',
|
||
|
|
description: 'Name and settings',
|
||
|
|
isValid: !!state.name && !!state.slug,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: 'Rounds',
|
||
|
|
description: 'Configure rounds',
|
||
|
|
isValid: state.rounds.length > 0,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: 'Jury Groups',
|
||
|
|
description: 'Add jury groups',
|
||
|
|
isValid: true, // Optional
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: 'Review',
|
||
|
|
description: 'Confirm and create',
|
||
|
|
isValid: !!state.name && !!state.slug && state.rounds.length > 0,
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
const canSubmit = steps.every((s) => s.isValid)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* Header */}
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<Link href={'/admin/competitions' as Route}>
|
||
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
|
||
|
|
<ArrowLeft className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</Link>
|
||
|
|
<div>
|
||
|
|
<h1 className="text-xl font-bold">New Competition</h1>
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
Create a multi-round competition workflow
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Wizard */}
|
||
|
|
<SidebarStepper
|
||
|
|
steps={steps}
|
||
|
|
currentStep={currentStep}
|
||
|
|
onStepChange={setCurrentStep}
|
||
|
|
onSubmit={handleSubmit}
|
||
|
|
isSubmitting={
|
||
|
|
createCompetitionMutation.isPending ||
|
||
|
|
createRoundMutation.isPending ||
|
||
|
|
createJuryGroupMutation.isPending
|
||
|
|
}
|
||
|
|
submitLabel="Create Competition"
|
||
|
|
canSubmit={canSubmit}
|
||
|
|
>
|
||
|
|
<BasicsSection state={state} onChange={handleStateChange} />
|
||
|
|
<RoundsSection rounds={state.rounds} onChange={(rounds) => handleStateChange({ rounds })} />
|
||
|
|
<JuryGroupsSection
|
||
|
|
juryGroups={state.juryGroups}
|
||
|
|
onChange={(juryGroups) => handleStateChange({ juryGroups })}
|
||
|
|
/>
|
||
|
|
<ReviewSection state={state} />
|
||
|
|
</SidebarStepper>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|