Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
156
src/components/admin/competition/competition-timeline.tsx
Normal file
156
src/components/admin/competition/competition-timeline.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { format } from 'date-fns'
|
||||
import { CheckCircle2, Circle, Clock } from 'lucide-react'
|
||||
|
||||
const roundTypeColors: Record<string, string> = {
|
||||
INTAKE: 'bg-gray-100 text-gray-700 border-gray-300',
|
||||
FILTERING: 'bg-amber-100 text-amber-700 border-amber-300',
|
||||
EVALUATION: 'bg-blue-100 text-blue-700 border-blue-300',
|
||||
SUBMISSION: 'bg-purple-100 text-purple-700 border-purple-300',
|
||||
MENTORING: 'bg-teal-100 text-teal-700 border-teal-300',
|
||||
LIVE_FINAL: 'bg-red-100 text-red-700 border-red-300',
|
||||
DELIBERATION: 'bg-indigo-100 text-indigo-700 border-indigo-300',
|
||||
}
|
||||
|
||||
const roundStatusConfig: Record<string, { icon: typeof Circle; color: string }> = {
|
||||
ROUND_DRAFT: { icon: Circle, color: 'text-gray-400' },
|
||||
ROUND_ACTIVE: { icon: Clock, color: 'text-emerald-500' },
|
||||
ROUND_CLOSED: { icon: CheckCircle2, color: 'text-blue-500' },
|
||||
ROUND_ARCHIVED: { icon: CheckCircle2, color: 'text-gray-400' },
|
||||
}
|
||||
|
||||
type RoundSummary = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
roundType: string
|
||||
status: string
|
||||
sortOrder: number
|
||||
windowOpenAt: Date | string | null
|
||||
windowCloseAt: Date | string | null
|
||||
}
|
||||
|
||||
export function CompetitionTimeline({
|
||||
competitionId,
|
||||
rounds,
|
||||
}: {
|
||||
competitionId: string
|
||||
rounds: RoundSummary[]
|
||||
}) {
|
||||
if (rounds.length === 0) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No rounds configured yet. Add rounds to see the competition timeline.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Round Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Desktop: horizontal timeline */}
|
||||
<div className="hidden md:block overflow-x-auto pb-2">
|
||||
<div className="flex items-start gap-0 min-w-max">
|
||||
{rounds.map((round, index) => {
|
||||
const statusCfg = roundStatusConfig[round.status] ?? roundStatusConfig.ROUND_DRAFT
|
||||
const StatusIcon = statusCfg.icon
|
||||
const isLast = index === rounds.length - 1
|
||||
|
||||
return (
|
||||
<div key={round.id} className="flex items-start">
|
||||
<Link
|
||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
||||
className="group flex flex-col items-center text-center w-32 shrink-0"
|
||||
>
|
||||
<div className="relative">
|
||||
<StatusIcon className={cn('h-6 w-6', statusCfg.color)} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-medium group-hover:text-primary transition-colors line-clamp-2">
|
||||
{round.name}
|
||||
</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'mt-1 text-[9px]',
|
||||
roundTypeColors[round.roundType] ?? ''
|
||||
)}
|
||||
>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
{round.windowOpenAt && (
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
{format(new Date(round.windowOpenAt), 'MMM d')}
|
||||
{round.windowCloseAt && (
|
||||
<> - {format(new Date(round.windowCloseAt), 'MMM d')}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
{!isLast && (
|
||||
<div className="mt-3 h-px w-8 bg-border shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: vertical timeline */}
|
||||
<div className="md:hidden space-y-0">
|
||||
{rounds.map((round, index) => {
|
||||
const statusCfg = roundStatusConfig[round.status] ?? roundStatusConfig.ROUND_DRAFT
|
||||
const StatusIcon = statusCfg.icon
|
||||
const isLast = index === rounds.length - 1
|
||||
|
||||
return (
|
||||
<div key={round.id}>
|
||||
<Link
|
||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
||||
className="flex items-start gap-3 py-2 hover:bg-muted/50 rounded-md px-2 -mx-2 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<StatusIcon className={cn('h-5 w-5', statusCfg.color)} />
|
||||
{!isLast && <div className="w-px flex-1 bg-border mt-1 min-h-[16px]" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">{round.name}</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[9px] shrink-0',
|
||||
roundTypeColors[round.roundType] ?? ''
|
||||
)}
|
||||
>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{round.windowOpenAt && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||||
{format(new Date(round.windowOpenAt), 'MMM d, yyyy')}
|
||||
{round.windowCloseAt && (
|
||||
<> - {format(new Date(round.windowCloseAt), 'MMM d, yyyy')}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
248
src/components/admin/competition/round-config-form.tsx
Normal file
248
src/components/admin/competition/round-config-form.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
type RoundConfigFormProps = {
|
||||
roundType: string
|
||||
config: Record<string, unknown>
|
||||
onChange: (config: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
export function RoundConfigForm({ roundType, config, onChange }: RoundConfigFormProps) {
|
||||
const updateConfig = (key: string, value: unknown) => {
|
||||
onChange({ ...config, [key]: value })
|
||||
}
|
||||
|
||||
if (roundType === 'INTAKE') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Intake Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="allowDrafts">Allow Drafts</Label>
|
||||
<Switch
|
||||
id="allowDrafts"
|
||||
checked={(config.allowDrafts as boolean) ?? true}
|
||||
onCheckedChange={(checked) => updateConfig('allowDrafts', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="draftExpiryDays">Draft Expiry (days)</Label>
|
||||
<Input
|
||||
id="draftExpiryDays"
|
||||
type="number"
|
||||
min={1}
|
||||
value={(config.draftExpiryDays as number) ?? 30}
|
||||
onChange={(e) => updateConfig('draftExpiryDays', parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxFileSizeMB">Max File Size (MB)</Label>
|
||||
<Input
|
||||
id="maxFileSizeMB"
|
||||
type="number"
|
||||
min={1}
|
||||
value={(config.maxFileSizeMB as number) ?? 50}
|
||||
onChange={(e) => updateConfig('maxFileSizeMB', parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="publicFormEnabled">Public Form Enabled</Label>
|
||||
<Switch
|
||||
id="publicFormEnabled"
|
||||
checked={(config.publicFormEnabled as boolean) ?? false}
|
||||
onCheckedChange={(checked) => updateConfig('publicFormEnabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (roundType === 'FILTERING') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Filtering Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="aiScreeningEnabled">AI Screening</Label>
|
||||
<Switch
|
||||
id="aiScreeningEnabled"
|
||||
checked={(config.aiScreeningEnabled as boolean) ?? true}
|
||||
onCheckedChange={(checked) => updateConfig('aiScreeningEnabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="duplicateDetectionEnabled">Duplicate Detection</Label>
|
||||
<Switch
|
||||
id="duplicateDetectionEnabled"
|
||||
checked={(config.duplicateDetectionEnabled as boolean) ?? true}
|
||||
onCheckedChange={(checked) => updateConfig('duplicateDetectionEnabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="manualReviewEnabled">Manual Review</Label>
|
||||
<Switch
|
||||
id="manualReviewEnabled"
|
||||
checked={(config.manualReviewEnabled as boolean) ?? true}
|
||||
onCheckedChange={(checked) => updateConfig('manualReviewEnabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batchSize">Batch Size</Label>
|
||||
<Input
|
||||
id="batchSize"
|
||||
type="number"
|
||||
min={1}
|
||||
value={(config.batchSize as number) ?? 20}
|
||||
onChange={(e) => updateConfig('batchSize', parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (roundType === 'EVALUATION') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Evaluation Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requiredReviews">Required Reviews per Project</Label>
|
||||
<Input
|
||||
id="requiredReviews"
|
||||
type="number"
|
||||
min={1}
|
||||
value={(config.requiredReviewsPerProject as number) ?? 3}
|
||||
onChange={(e) => updateConfig('requiredReviewsPerProject', parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoringMode">Scoring Mode</Label>
|
||||
<Select
|
||||
value={(config.scoringMode as string) ?? 'criteria'}
|
||||
onValueChange={(value) => updateConfig('scoringMode', value)}
|
||||
>
|
||||
<SelectTrigger id="scoringMode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="criteria">Criteria-based</SelectItem>
|
||||
<SelectItem value="global">Global score</SelectItem>
|
||||
<SelectItem value="binary">Binary (pass/fail)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="requireFeedback">Require Feedback</Label>
|
||||
<Switch
|
||||
id="requireFeedback"
|
||||
checked={(config.requireFeedback as boolean) ?? true}
|
||||
onCheckedChange={(checked) => updateConfig('requireFeedback', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="coiRequired">COI Declaration Required</Label>
|
||||
<Switch
|
||||
id="coiRequired"
|
||||
checked={(config.coiRequired as boolean) ?? true}
|
||||
onCheckedChange={(checked) => updateConfig('coiRequired', checked)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (roundType === 'LIVE_FINAL') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Live Final Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="juryVotingEnabled">Jury Voting</Label>
|
||||
<Switch
|
||||
id="juryVotingEnabled"
|
||||
checked={(config.juryVotingEnabled as boolean) ?? true}
|
||||
onCheckedChange={(checked) => updateConfig('juryVotingEnabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="audienceVotingEnabled">Audience Voting</Label>
|
||||
<Switch
|
||||
id="audienceVotingEnabled"
|
||||
checked={(config.audienceVotingEnabled as boolean) ?? false}
|
||||
onCheckedChange={(checked) => updateConfig('audienceVotingEnabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(config.audienceVotingEnabled as boolean) && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="audienceVoteWeight">Audience Vote Weight (0-1)</Label>
|
||||
<Input
|
||||
id="audienceVoteWeight"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={(config.audienceVoteWeight as number) ?? 0}
|
||||
onChange={(e) => updateConfig('audienceVoteWeight', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="presentationDuration">Presentation Duration (min)</Label>
|
||||
<Input
|
||||
id="presentationDuration"
|
||||
type="number"
|
||||
min={1}
|
||||
value={(config.presentationDurationMinutes as number) ?? 15}
|
||||
onChange={(e) => updateConfig('presentationDurationMinutes', parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Default view for other types
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{roundType} Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configuration UI for {roundType} rounds is not yet implemented.
|
||||
</p>
|
||||
<pre className="mt-4 p-3 bg-muted rounded text-xs overflow-auto">
|
||||
{JSON.stringify(config, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
159
src/components/admin/competition/sections/basics-section.tsx
Normal file
159
src/components/admin/competition/sections/basics-section.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
type WizardState = {
|
||||
programId: string
|
||||
name: string
|
||||
slug: string
|
||||
categoryMode: string
|
||||
startupFinalistCount: number
|
||||
conceptFinalistCount: number
|
||||
notifyOnRoundAdvance: boolean
|
||||
notifyOnDeadlineApproach: boolean
|
||||
deadlineReminderDays: number[]
|
||||
}
|
||||
|
||||
type BasicsSectionProps = {
|
||||
state: WizardState
|
||||
onChange: (updates: Partial<WizardState>) => void
|
||||
}
|
||||
|
||||
export function BasicsSection({ state, onChange }: BasicsSectionProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Competition Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Competition Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g., 2026 Ocean Innovation Challenge"
|
||||
value={state.name}
|
||||
onChange={(e) => onChange({ name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">Slug *</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
placeholder="e.g., 2026-ocean-innovation"
|
||||
value={state.slug}
|
||||
onChange={(e) => onChange({ slug: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL-safe identifier (lowercase, hyphens only)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoryMode">Category Mode</Label>
|
||||
<Select value={state.categoryMode} onValueChange={(value) => onChange({ categoryMode: value })}>
|
||||
<SelectTrigger id="categoryMode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SHARED">Shared (all categories together)</SelectItem>
|
||||
<SelectItem value="SEPARATE">Separate (categories evaluated independently)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startupFinalists">Startup Finalists</Label>
|
||||
<Input
|
||||
id="startupFinalists"
|
||||
type="number"
|
||||
min={1}
|
||||
value={state.startupFinalistCount}
|
||||
onChange={(e) => onChange({ startupFinalistCount: parseInt(e.target.value, 10) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conceptFinalists">Concept Finalists</Label>
|
||||
<Input
|
||||
id="conceptFinalists"
|
||||
type="number"
|
||||
min={1}
|
||||
value={state.conceptFinalistCount}
|
||||
onChange={(e) => onChange({ conceptFinalistCount: parseInt(e.target.value, 10) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Notifications</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notifyRoundAdvance">Round Advancement Notifications</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Notify participants when they advance to the next round
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifyRoundAdvance"
|
||||
checked={state.notifyOnRoundAdvance}
|
||||
onCheckedChange={(checked) => onChange({ notifyOnRoundAdvance: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notifyDeadline">Deadline Reminders</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send reminders as deadlines approach
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifyDeadline"
|
||||
checked={state.notifyOnDeadlineApproach}
|
||||
onCheckedChange={(checked) => onChange({ notifyOnDeadlineApproach: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.notifyOnDeadlineApproach && (
|
||||
<div className="space-y-2">
|
||||
<Label>Reminder Days</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state.deadlineReminderDays.map((days, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-16"
|
||||
value={days}
|
||||
onChange={(e) => {
|
||||
const newDays = [...state.deadlineReminderDays]
|
||||
newDays[index] = parseInt(e.target.value, 10)
|
||||
onChange({ deadlineReminderDays: newDays })
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">days</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Days before deadline to send reminders
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
type WizardJuryGroup = {
|
||||
tempId: string
|
||||
name: string
|
||||
slug: string
|
||||
defaultMaxAssignments: number
|
||||
defaultCapMode: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
type JuryGroupsSectionProps = {
|
||||
juryGroups: WizardJuryGroup[]
|
||||
onChange: (groups: WizardJuryGroup[]) => void
|
||||
}
|
||||
|
||||
export function JuryGroupsSection({ juryGroups, onChange }: JuryGroupsSectionProps) {
|
||||
const handleAddGroup = () => {
|
||||
const newGroup: WizardJuryGroup = {
|
||||
tempId: crypto.randomUUID(),
|
||||
name: '',
|
||||
slug: '',
|
||||
defaultMaxAssignments: 5,
|
||||
defaultCapMode: 'SOFT',
|
||||
sortOrder: juryGroups.length,
|
||||
}
|
||||
onChange([...juryGroups, newGroup])
|
||||
}
|
||||
|
||||
const handleRemoveGroup = (tempId: string) => {
|
||||
const updated = juryGroups.filter((g) => g.tempId !== tempId)
|
||||
const reordered = updated.map((g, index) => ({ ...g, sortOrder: index }))
|
||||
onChange(reordered)
|
||||
}
|
||||
|
||||
const handleUpdateGroup = (tempId: string, updates: Partial<WizardJuryGroup>) => {
|
||||
const updated = juryGroups.map((g) =>
|
||||
g.tempId === tempId ? { ...g, ...updates } : g
|
||||
)
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Jury Groups</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create jury groups for evaluation rounds (optional)
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{juryGroups.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No jury groups yet. Add groups to assign evaluators to rounds.
|
||||
</div>
|
||||
) : (
|
||||
juryGroups.map((group, index) => (
|
||||
<div key={group.tempId} className="flex items-start gap-2 border rounded-lg p-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Group Name</Label>
|
||||
<Input
|
||||
placeholder="e.g., Technical Jury"
|
||||
value={group.name}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value
|
||||
const slug = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||
handleUpdateGroup(group.tempId, { name, slug })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Slug</Label>
|
||||
<Input
|
||||
placeholder="e.g., technical-jury"
|
||||
value={group.slug}
|
||||
onChange={(e) => handleUpdateGroup(group.tempId, { slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Max Assignments per Juror</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={group.defaultMaxAssignments}
|
||||
onChange={(e) =>
|
||||
handleUpdateGroup(group.tempId, {
|
||||
defaultMaxAssignments: parseInt(e.target.value, 10),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Cap Mode</Label>
|
||||
<Select
|
||||
value={group.defaultCapMode}
|
||||
onValueChange={(value) => handleUpdateGroup(group.tempId, { defaultCapMode: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HARD">Hard (strict limit)</SelectItem>
|
||||
<SelectItem value="SOFT">Soft (flexible)</SelectItem>
|
||||
<SelectItem value="NONE">None (unlimited)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive shrink-0"
|
||||
onClick={() => handleRemoveGroup(group.tempId)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={handleAddGroup}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Jury Group
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
213
src/components/admin/competition/sections/review-section.tsx
Normal file
213
src/components/admin/competition/sections/review-section.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
type ReviewSectionProps = {
|
||||
state: WizardState
|
||||
}
|
||||
|
||||
const roundTypeColors: Record<string, string> = {
|
||||
INTAKE: 'bg-gray-100 text-gray-700',
|
||||
FILTERING: 'bg-amber-100 text-amber-700',
|
||||
EVALUATION: 'bg-blue-100 text-blue-700',
|
||||
SUBMISSION: 'bg-purple-100 text-purple-700',
|
||||
MENTORING: 'bg-teal-100 text-teal-700',
|
||||
LIVE_FINAL: 'bg-red-100 text-red-700',
|
||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||
}
|
||||
|
||||
export function ReviewSection({ state }: ReviewSectionProps) {
|
||||
const warnings: string[] = []
|
||||
|
||||
if (!state.name) warnings.push('Competition name is required')
|
||||
if (!state.slug) warnings.push('Competition slug is required')
|
||||
if (state.rounds.length === 0) warnings.push('At least one round is required')
|
||||
if (state.rounds.some((r) => !r.name)) warnings.push('All rounds must have a name')
|
||||
if (state.rounds.some((r) => !r.slug)) warnings.push('All rounds must have a slug')
|
||||
if (state.juryGroups.some((g) => !g.name)) warnings.push('All jury groups must have a name')
|
||||
if (state.juryGroups.some((g) => !g.slug)) warnings.push('All jury groups must have a slug')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Validation Status */}
|
||||
{warnings.length > 0 ? (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
Please fix the following issues:
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1 text-sm text-destructive/90">
|
||||
{warnings.map((warning, index) => (
|
||||
<li key={index} className="ml-4 list-disc">
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="border-emerald-500/50 bg-emerald-500/5">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
|
||||
<p className="text-sm font-medium text-emerald-700">
|
||||
Ready to create competition
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Competition Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Competition Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Name</p>
|
||||
<p className="text-sm">{state.name || <em className="text-muted-foreground">Not set</em>}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Slug</p>
|
||||
<p className="text-sm font-mono">{state.slug || <em className="text-muted-foreground">Not set</em>}</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Category Mode</p>
|
||||
<p className="text-sm">{state.categoryMode}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Finalists</p>
|
||||
<p className="text-sm">
|
||||
{state.startupFinalistCount} Startup / {state.conceptFinalistCount} Concept
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5">Notifications</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{state.notifyOnRoundAdvance && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Round Advance
|
||||
</Badge>
|
||||
)}
|
||||
{state.notifyOnDeadlineApproach && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Deadline Reminders ({state.deadlineReminderDays.join(', ')} days)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rounds Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Rounds ({state.rounds.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{state.rounds.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No rounds configured</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{state.rounds.map((round, index) => (
|
||||
<div key={round.tempId} className="flex items-center gap-3 py-2 border-b last:border-0">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{round.name || <em>Unnamed</em>}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{round.slug || <em>no-slug</em>}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}
|
||||
>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Jury Groups Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Jury Groups ({state.juryGroups.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{state.juryGroups.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No jury groups configured</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{state.juryGroups.map((group, index) => (
|
||||
<div key={group.tempId} className="flex items-center gap-3 py-2 border-b last:border-0">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{group.name || <em>Unnamed</em>}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{group.slug || <em>no-slug</em>}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
||||
<span>Max: {group.defaultMaxAssignments}</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{group.defaultCapMode}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
195
src/components/admin/competition/sections/rounds-section.tsx
Normal file
195
src/components/admin/competition/sections/rounds-section.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
|
||||
type WizardRound = {
|
||||
tempId: string
|
||||
name: string
|
||||
slug: string
|
||||
roundType: string
|
||||
sortOrder: number
|
||||
configJson: Record<string, unknown>
|
||||
}
|
||||
|
||||
type RoundsSectionProps = {
|
||||
rounds: WizardRound[]
|
||||
onChange: (rounds: WizardRound[]) => void
|
||||
}
|
||||
|
||||
const roundTypes = [
|
||||
{ value: 'INTAKE', label: 'Intake', color: 'bg-gray-100 text-gray-700' },
|
||||
{ value: 'FILTERING', label: 'Filtering', color: 'bg-amber-100 text-amber-700' },
|
||||
{ value: 'EVALUATION', label: 'Evaluation', color: 'bg-blue-100 text-blue-700' },
|
||||
{ value: 'SUBMISSION', label: 'Submission', color: 'bg-purple-100 text-purple-700' },
|
||||
{ value: 'MENTORING', label: 'Mentoring', color: 'bg-teal-100 text-teal-700' },
|
||||
{ value: 'LIVE_FINAL', label: 'Live Final', color: 'bg-red-100 text-red-700' },
|
||||
{ value: 'DELIBERATION', label: 'Deliberation', color: 'bg-indigo-100 text-indigo-700' },
|
||||
]
|
||||
|
||||
export function RoundsSection({ rounds, onChange }: RoundsSectionProps) {
|
||||
const handleAddRound = () => {
|
||||
const newRound: WizardRound = {
|
||||
tempId: crypto.randomUUID(),
|
||||
name: '',
|
||||
slug: '',
|
||||
roundType: 'EVALUATION',
|
||||
sortOrder: rounds.length,
|
||||
configJson: {},
|
||||
}
|
||||
onChange([...rounds, newRound])
|
||||
}
|
||||
|
||||
const handleRemoveRound = (tempId: string) => {
|
||||
const updated = rounds.filter((r) => r.tempId !== tempId)
|
||||
// Reorder
|
||||
const reordered = updated.map((r, index) => ({ ...r, sortOrder: index }))
|
||||
onChange(reordered)
|
||||
}
|
||||
|
||||
const handleUpdateRound = (tempId: string, updates: Partial<WizardRound>) => {
|
||||
const updated = rounds.map((r) =>
|
||||
r.tempId === tempId ? { ...r, ...updates } : r
|
||||
)
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (index === 0) return
|
||||
const updated = [...rounds]
|
||||
;[updated[index - 1], updated[index]] = [updated[index], updated[index - 1]]
|
||||
const reordered = updated.map((r, i) => ({ ...r, sortOrder: i }))
|
||||
onChange(reordered)
|
||||
}
|
||||
|
||||
const handleMoveDown = (index: number) => {
|
||||
if (index === rounds.length - 1) return
|
||||
const updated = [...rounds]
|
||||
;[updated[index], updated[index + 1]] = [updated[index + 1], updated[index]]
|
||||
const reordered = updated.map((r, i) => ({ ...r, sortOrder: i }))
|
||||
onChange(reordered)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Competition Rounds</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Define the stages of your competition workflow
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{rounds.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No rounds yet. Add your first round to get started.
|
||||
</div>
|
||||
) : (
|
||||
rounds.map((round, index) => {
|
||||
const typeConfig = roundTypes.find((t) => t.value === round.roundType)
|
||||
return (
|
||||
<div key={round.tempId} className="flex items-start gap-2 border rounded-lg p-3">
|
||||
<div className="flex flex-col gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleMoveDown(index)}
|
||||
disabled={index === rounds.length - 1}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Round Name</Label>
|
||||
<Input
|
||||
placeholder="e.g., First Evaluation"
|
||||
value={round.name}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value
|
||||
const slug = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||
handleUpdateRound(round.tempId, { name, slug })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Slug</Label>
|
||||
<Input
|
||||
placeholder="e.g., first-evaluation"
|
||||
value={round.slug}
|
||||
onChange={(e) => handleUpdateRound(round.tempId, { slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">Round Type</Label>
|
||||
<Select
|
||||
value={round.roundType}
|
||||
onValueChange={(value) => handleUpdateRound(round.tempId, { roundType: value })}
|
||||
>
|
||||
<SelectTrigger className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roundTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{typeConfig && (
|
||||
<Badge variant="secondary" className={typeConfig.color}>
|
||||
{typeConfig.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive shrink-0"
|
||||
onClick={() => handleRemoveRound(round.tempId)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={handleAddRound}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Round
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user