All checks were successful
Build and Push Docker Image / build (push) Successful in 12m44s
Redesign the rounds page from a card-per-competition layout to a flat pipeline/timeline view. Rounds display as compact rows connected by a vertical track with status dots (pulsing green for active). Special awards branch off to the right from their linked evaluation round with connector lines and tooltip details. Competition settings moved to a dialog behind a gear icon. Filter pills replace the dropdown selector. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
741 lines
32 KiB
TypeScript
741 lines
32 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo } from 'react'
|
|
import Link from 'next/link'
|
|
import type { Route } from 'next'
|
|
import { trpc } from '@/lib/trpc/client'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
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'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from '@/components/ui/tooltip'
|
|
import { toast } from 'sonner'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
Plus,
|
|
Calendar,
|
|
Settings,
|
|
Users,
|
|
FileBox,
|
|
Save,
|
|
Loader2,
|
|
Award,
|
|
Trophy,
|
|
ArrowRight,
|
|
} from 'lucide-react'
|
|
import { useEdition } from '@/contexts/edition-context'
|
|
|
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
|
|
const ROUND_TYPES = [
|
|
{ value: 'INTAKE', label: 'Intake' },
|
|
{ value: 'FILTERING', label: 'Filtering' },
|
|
{ value: 'EVALUATION', label: 'Evaluation' },
|
|
{ value: 'SUBMISSION', label: 'Submission' },
|
|
{ value: 'MENTORING', label: 'Mentoring' },
|
|
{ value: 'LIVE_FINAL', label: 'Live Final' },
|
|
{ value: 'DELIBERATION', label: 'Deliberation' },
|
|
] as const
|
|
|
|
const ROUND_TYPE_COLORS: Record<string, { dot: string; bg: string; text: string; border: string }> = {
|
|
INTAKE: { dot: '#9ca3af', bg: 'bg-gray-50', text: 'text-gray-600', border: 'border-gray-300' },
|
|
FILTERING: { dot: '#f59e0b', bg: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-300' },
|
|
EVALUATION: { dot: '#3b82f6', bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-300' },
|
|
SUBMISSION: { dot: '#8b5cf6', bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-300' },
|
|
MENTORING: { dot: '#557f8c', bg: 'bg-teal-50', text: 'text-teal-700', border: 'border-teal-300' },
|
|
LIVE_FINAL: { dot: '#de0f1e', bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-300' },
|
|
DELIBERATION: { dot: '#6366f1', bg: 'bg-indigo-50', text: 'text-indigo-700', border: 'border-indigo-300' },
|
|
}
|
|
|
|
const ROUND_STATUS_STYLES: Record<string, { color: string; label: string; pulse?: boolean }> = {
|
|
ROUND_DRAFT: { color: '#9ca3af', label: 'Draft' },
|
|
ROUND_ACTIVE: { color: '#10b981', label: 'Active', pulse: true },
|
|
ROUND_CLOSED: { color: '#3b82f6', label: 'Closed' },
|
|
ROUND_ARCHIVED: { color: '#6b7280', label: 'Archived' },
|
|
}
|
|
|
|
const AWARD_STATUS_COLORS: Record<string, string> = {
|
|
DRAFT: 'text-gray-500',
|
|
NOMINATIONS_OPEN: 'text-amber-600',
|
|
VOTING_OPEN: 'text-emerald-600',
|
|
CLOSED: 'text-blue-600',
|
|
ARCHIVED: 'text-gray-400',
|
|
}
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
type RoundWithStats = {
|
|
id: string
|
|
name: string
|
|
slug: string
|
|
roundType: string
|
|
status: string
|
|
sortOrder: number
|
|
windowOpenAt: string | null
|
|
windowCloseAt: string | null
|
|
juryGroup: { id: string; name: string } | null
|
|
_count: { projectRoundStates: number; assignments: number }
|
|
}
|
|
|
|
type SpecialAwardItem = {
|
|
id: string
|
|
name: string
|
|
status: string
|
|
evaluationRoundId: string | null
|
|
eligibilityMode: string
|
|
_count: { eligibilities: number; jurors: number; votes: number }
|
|
winnerProject: { id: string; title: string; teamName: string | null } | null
|
|
}
|
|
|
|
// ─── Main Page ───────────────────────────────────────────────────────────────
|
|
|
|
export default function RoundsPage() {
|
|
const { currentEdition } = useEdition()
|
|
const programId = currentEdition?.id
|
|
const utils = trpc.useUtils()
|
|
|
|
const [addRoundOpen, setAddRoundOpen] = useState(false)
|
|
const [roundForm, setRoundForm] = useState({ name: '', roundType: '', competitionId: '' })
|
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
const [competitionEdits, setCompetitionEdits] = useState<Record<string, unknown>>({})
|
|
const [editingCompId, setEditingCompId] = useState<string | null>(null)
|
|
const [filterType, setFilterType] = useState<string>('all')
|
|
|
|
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
|
|
{ programId: programId! },
|
|
{ enabled: !!programId }
|
|
)
|
|
|
|
// Use the first (and usually only) competition
|
|
const comp = competitions?.[0]
|
|
|
|
const { data: compDetail, isLoading: isLoadingDetail } = trpc.competition.getById.useQuery(
|
|
{ id: comp?.id! },
|
|
{ enabled: !!comp?.id }
|
|
)
|
|
|
|
const { data: awards } = trpc.specialAward.list.useQuery(
|
|
{ programId: programId! },
|
|
{ enabled: !!programId }
|
|
)
|
|
|
|
const createRoundMutation = trpc.round.create.useMutation({
|
|
onSuccess: () => {
|
|
utils.competition.list.invalidate()
|
|
utils.competition.getById.invalidate()
|
|
toast.success('Round created')
|
|
setAddRoundOpen(false)
|
|
setRoundForm({ name: '', roundType: '', competitionId: '' })
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
const updateCompMutation = trpc.competition.update.useMutation({
|
|
onSuccess: () => {
|
|
utils.competition.list.invalidate()
|
|
utils.competition.getById.invalidate()
|
|
toast.success('Settings saved')
|
|
setEditingCompId(null)
|
|
setCompetitionEdits({})
|
|
setSettingsOpen(false)
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
const rounds = useMemo(() => {
|
|
const all = (compDetail?.rounds ?? []) as RoundWithStats[]
|
|
return filterType === 'all' ? all : all.filter((r) => r.roundType === filterType)
|
|
}, [compDetail?.rounds, filterType])
|
|
|
|
// Group awards by their evaluationRoundId
|
|
const awardsByRound = useMemo(() => {
|
|
const map = new Map<string, SpecialAwardItem[]>()
|
|
for (const award of (awards ?? []) as SpecialAwardItem[]) {
|
|
if (award.evaluationRoundId) {
|
|
const existing = map.get(award.evaluationRoundId) ?? []
|
|
existing.push(award)
|
|
map.set(award.evaluationRoundId, existing)
|
|
}
|
|
}
|
|
return map
|
|
}, [awards])
|
|
|
|
const floatingAwards = useMemo(() => {
|
|
return ((awards ?? []) as SpecialAwardItem[]).filter((a) => !a.evaluationRoundId)
|
|
}, [awards])
|
|
|
|
const handleCreateRound = () => {
|
|
if (!roundForm.name.trim() || !roundForm.roundType || !comp) {
|
|
toast.error('All fields are required')
|
|
return
|
|
}
|
|
const slug = roundForm.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
const nextOrder = rounds.length
|
|
createRoundMutation.mutate({
|
|
competitionId: comp.id,
|
|
name: roundForm.name.trim(),
|
|
slug,
|
|
roundType: roundForm.roundType as any,
|
|
sortOrder: nextOrder,
|
|
})
|
|
}
|
|
|
|
const startEditSettings = () => {
|
|
if (!comp) return
|
|
setEditingCompId(comp.id)
|
|
setCompetitionEdits({
|
|
name: comp.name,
|
|
categoryMode: (comp as any).categoryMode,
|
|
startupFinalistCount: (comp as any).startupFinalistCount,
|
|
conceptFinalistCount: (comp as any).conceptFinalistCount,
|
|
notifyOnDeadlineApproach: (comp as any).notifyOnDeadlineApproach,
|
|
})
|
|
setSettingsOpen(true)
|
|
}
|
|
|
|
const saveSettings = () => {
|
|
if (!editingCompId) return
|
|
updateCompMutation.mutate({ id: editingCompId, ...competitionEdits } as any)
|
|
}
|
|
|
|
// ─── No edition ──────────────────────────────────────────────────────────
|
|
|
|
if (!programId) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<h1 className="text-xl font-bold">Rounds</h1>
|
|
<div className="flex flex-col items-center justify-center py-16 text-center border-2 border-dashed rounded-lg">
|
|
<Calendar className="h-10 w-10 text-muted-foreground/40 mb-3" />
|
|
<p className="font-medium text-sm">No Edition Selected</p>
|
|
<p className="text-xs text-muted-foreground">Select an edition from the sidebar</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Loading ─────────────────────────────────────────────────────────────
|
|
|
|
if (isLoading || isLoadingDetail) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<Skeleton className="h-8 w-64" />
|
|
<Skeleton className="h-9 w-28" />
|
|
</div>
|
|
<Skeleton className="h-10 w-full" />
|
|
<div className="space-y-1">
|
|
{[1, 2, 3, 4, 5].map((i) => (
|
|
<Skeleton key={i} className="h-14 w-full" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── No competition ──────────────────────────────────────────────────────
|
|
|
|
if (!comp) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<h1 className="text-2xl font-bold tracking-tight text-[#053d57]">Competition Pipeline</h1>
|
|
<div className="flex flex-col items-center justify-center py-20 text-center border-2 border-dashed rounded-lg">
|
|
<div className="rounded-full bg-[#053d57]/5 p-5 mb-4">
|
|
<Trophy className="h-10 w-10 text-[#053d57]/60" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-[#053d57] mb-1">No Competition Configured</h3>
|
|
<p className="text-sm text-muted-foreground max-w-sm mb-5">
|
|
Create a competition to start building the evaluation pipeline.
|
|
</p>
|
|
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
|
|
<Button className="bg-[#de0f1e] hover:bg-[#de0f1e]/90">
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Create Competition
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Main Render ─────────────────────────────────────────────────────────
|
|
|
|
const activeFilter = filterType !== 'all'
|
|
const totalProjects = rounds.reduce((s, r) => s + r._count.projectRoundStates, 0)
|
|
const totalAssignments = rounds.reduce((s, r) => s + r._count.assignments, 0)
|
|
const activeRound = rounds.find((r) => r.status === 'ROUND_ACTIVE')
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={200}>
|
|
<div className="space-y-5">
|
|
{/* ── Header Bar ──────────────────────────────────────────────── */}
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-2xl font-bold tracking-tight text-[#053d57] truncate">
|
|
{comp.name}
|
|
</h1>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={startEditSettings}
|
|
className="shrink-0 p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-[#053d57]"
|
|
>
|
|
<Settings className="h-4 w-4" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Competition settings</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
|
|
<span>{rounds.length} rounds</span>
|
|
<span className="text-muted-foreground/30">|</span>
|
|
<span>{totalProjects} projects</span>
|
|
<span className="text-muted-foreground/30">|</span>
|
|
<span>{totalAssignments} assignments</span>
|
|
{activeRound && (
|
|
<>
|
|
<span className="text-muted-foreground/30">|</span>
|
|
<span className="flex items-center gap-1.5 text-emerald-600 font-medium">
|
|
<span className="relative flex h-2 w-2">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
|
|
</span>
|
|
{activeRound.name}
|
|
</span>
|
|
</>
|
|
)}
|
|
{awards && awards.length > 0 && (
|
|
<>
|
|
<span className="text-muted-foreground/30">|</span>
|
|
<span className="flex items-center gap-1">
|
|
<Award className="h-3.5 w-3.5" />
|
|
{awards.length} awards
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setAddRoundOpen(true)}
|
|
className="bg-[#de0f1e] hover:bg-[#de0f1e]/90 shrink-0"
|
|
>
|
|
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
Add Round
|
|
</Button>
|
|
</div>
|
|
|
|
{/* ── Filter Pills ────────────────────────────────────────────── */}
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<button
|
|
onClick={() => setFilterType('all')}
|
|
className={cn(
|
|
'px-3 py-1 text-xs font-medium rounded-full border transition-colors',
|
|
filterType === 'all'
|
|
? 'bg-[#053d57] text-white border-[#053d57]'
|
|
: 'bg-white text-muted-foreground border-border hover:border-[#053d57]/30'
|
|
)}
|
|
>
|
|
All
|
|
</button>
|
|
{ROUND_TYPES.map((rt) => {
|
|
const colors = ROUND_TYPE_COLORS[rt.value]
|
|
const isActive = filterType === rt.value
|
|
return (
|
|
<button
|
|
key={rt.value}
|
|
onClick={() => setFilterType(isActive ? 'all' : rt.value)}
|
|
className={cn(
|
|
'px-3 py-1 text-xs font-medium rounded-full border transition-colors flex items-center gap-1.5',
|
|
isActive
|
|
? `${colors.bg} ${colors.text} ${colors.border}`
|
|
: 'bg-white text-muted-foreground border-border hover:border-[#053d57]/30'
|
|
)}
|
|
>
|
|
<span
|
|
className="h-2 w-2 rounded-full shrink-0"
|
|
style={{ backgroundColor: colors.dot }}
|
|
/>
|
|
{rt.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* ── Pipeline View ───────────────────────────────────────────── */}
|
|
{rounds.length === 0 ? (
|
|
<div className="py-16 text-center border-2 border-dashed rounded-lg">
|
|
<FileBox className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
|
|
<p className="text-sm text-muted-foreground">
|
|
{activeFilter ? 'No rounds match this filter.' : 'No rounds yet. Add one to start building the pipeline.'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="relative">
|
|
{/* Main pipeline track */}
|
|
{rounds.map((round, index) => {
|
|
const isLast = index === rounds.length - 1
|
|
const typeColors = ROUND_TYPE_COLORS[round.roundType] ?? ROUND_TYPE_COLORS.INTAKE
|
|
const statusStyle = ROUND_STATUS_STYLES[round.status] ?? ROUND_STATUS_STYLES.ROUND_DRAFT
|
|
const projectCount = round._count.projectRoundStates
|
|
const assignmentCount = round._count.assignments
|
|
const roundAwards = awardsByRound.get(round.id) ?? []
|
|
|
|
return (
|
|
<div key={round.id} className="relative">
|
|
{/* Round row with pipeline connector */}
|
|
<div className="flex">
|
|
{/* Left: pipeline track */}
|
|
<div className="flex flex-col items-center shrink-0 w-10">
|
|
{/* Status dot */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="relative z-10 flex items-center justify-center">
|
|
<div
|
|
className="h-3.5 w-3.5 rounded-full border-2 border-white shadow-sm"
|
|
style={{ backgroundColor: statusStyle.color }}
|
|
/>
|
|
{statusStyle.pulse && (
|
|
<div
|
|
className="absolute h-3.5 w-3.5 rounded-full animate-ping opacity-40"
|
|
style={{ backgroundColor: statusStyle.color }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="left" className="text-xs">
|
|
{statusStyle.label}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
{/* Connector line */}
|
|
{!isLast && (
|
|
<div className="w-px flex-1 min-h-[8px] bg-border" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: round content + awards */}
|
|
<div className="flex-1 min-w-0 pb-2">
|
|
<div className="flex items-stretch gap-3">
|
|
{/* Round row */}
|
|
<Link
|
|
href={`/admin/rounds/${round.id}` as Route}
|
|
className="flex-1 min-w-0"
|
|
>
|
|
<div
|
|
className={cn(
|
|
'group flex items-center gap-3 px-3 py-2.5 rounded-md border-l-[3px] cursor-pointer transition-all',
|
|
'bg-white hover:bg-gray-50/80 hover:shadow-sm',
|
|
)}
|
|
style={{ borderLeftColor: typeColors.dot }}
|
|
>
|
|
{/* Round type indicator */}
|
|
<span className={cn(
|
|
'text-[10px] font-semibold uppercase tracking-wider shrink-0 w-[70px]',
|
|
typeColors.text
|
|
)}>
|
|
{round.roundType.replace('_', ' ')}
|
|
</span>
|
|
|
|
{/* Round name */}
|
|
<span className="text-sm font-semibold text-[#053d57] truncate group-hover:text-[#de0f1e] transition-colors min-w-0 flex-1">
|
|
{round.name}
|
|
</span>
|
|
|
|
{/* Stats cluster */}
|
|
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground shrink-0">
|
|
{round.juryGroup && (
|
|
<span className="flex items-center gap-1 max-w-[120px]">
|
|
<Users className="h-3 w-3 shrink-0" />
|
|
<span className="truncate">{round.juryGroup.name}</span>
|
|
</span>
|
|
)}
|
|
<span className="flex items-center gap-1">
|
|
<FileBox className="h-3 w-3 shrink-0" />
|
|
{projectCount}
|
|
</span>
|
|
{assignmentCount > 0 && (
|
|
<span className="tabular-nums">{assignmentCount} eval</span>
|
|
)}
|
|
{(round.windowOpenAt || round.windowCloseAt) && (
|
|
<span className="flex items-center gap-1 tabular-nums">
|
|
<Calendar className="h-3 w-3 shrink-0" />
|
|
{round.windowOpenAt
|
|
? new Date(round.windowOpenAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
|
|
: ''}
|
|
{round.windowOpenAt && round.windowCloseAt ? ' \u2013 ' : ''}
|
|
{round.windowCloseAt
|
|
? new Date(round.windowCloseAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
|
|
: ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status badge (compact) */}
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px] px-1.5 py-0 h-5 font-medium shrink-0 hidden md:inline-flex"
|
|
style={{ color: statusStyle.color, borderColor: statusStyle.color + '40' }}
|
|
>
|
|
{statusStyle.label}
|
|
</Badge>
|
|
|
|
{/* Arrow */}
|
|
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/30 group-hover:text-[#de0f1e]/60 transition-colors shrink-0" />
|
|
</div>
|
|
</Link>
|
|
|
|
{/* Awards branching off this round */}
|
|
{roundAwards.length > 0 && (
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{/* Connector dash */}
|
|
<div className="w-4 h-px bg-amber-300" />
|
|
{/* Award nodes */}
|
|
<div className="flex flex-col gap-1">
|
|
{roundAwards.map((award) => (
|
|
<AwardNode key={award.id} award={award} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* Floating awards (no evaluationRoundId) */}
|
|
{floatingAwards.length > 0 && (
|
|
<div className="mt-4 pt-4 border-t border-dashed">
|
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-2 pl-10">
|
|
Unlinked Awards
|
|
</p>
|
|
<div className="flex flex-wrap gap-2 pl-10">
|
|
{floatingAwards.map((award) => (
|
|
<AwardNode key={award.id} award={award} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Settings Panel (Collapsible) ─────────────────────────── */}
|
|
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-[#053d57]">Competition Settings</DialogTitle>
|
|
<DialogDescription>
|
|
Configure competition parameters for {comp.name}.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<Label className="text-xs font-medium">Competition Name</Label>
|
|
<Input
|
|
value={(competitionEdits.name as string) ?? ''}
|
|
onChange={(e) => setCompetitionEdits({ ...competitionEdits, name: e.target.value })}
|
|
className="h-9"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium">Category Mode</Label>
|
|
<Input
|
|
value={(competitionEdits.categoryMode as string) ?? ''}
|
|
onChange={(e) => setCompetitionEdits({ ...competitionEdits, categoryMode: e.target.value })}
|
|
className="h-9"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium">Startup Finalists</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
className="h-9"
|
|
value={(competitionEdits.startupFinalistCount as number) ?? 10}
|
|
onChange={(e) => setCompetitionEdits({ ...competitionEdits, startupFinalistCount: parseInt(e.target.value, 10) || 10 })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium">Concept Finalists</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
className="h-9"
|
|
value={(competitionEdits.conceptFinalistCount as number) ?? 10}
|
|
onChange={(e) => setCompetitionEdits({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3 sm:col-span-2 pt-1">
|
|
<Switch
|
|
checked={(competitionEdits.notifyOnDeadlineApproach as boolean) ?? false}
|
|
onCheckedChange={(v) => setCompetitionEdits({ ...competitionEdits, notifyOnDeadlineApproach: v })}
|
|
/>
|
|
<Label className="text-xs font-medium">Deadline Reminders</Label>
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => setSettingsOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={saveSettings}
|
|
disabled={updateCompMutation.isPending}
|
|
className="bg-[#de0f1e] hover:bg-[#de0f1e]/90"
|
|
>
|
|
{updateCompMutation.isPending ? (
|
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
) : (
|
|
<Save className="h-3.5 w-3.5 mr-1.5" />
|
|
)}
|
|
Save
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── Add Round Dialog ─────────────────────────────────────── */}
|
|
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Add Round</DialogTitle>
|
|
<DialogDescription>
|
|
Add a new round to the pipeline.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium">Round Name *</Label>
|
|
<Input
|
|
placeholder="e.g. Initial Screening"
|
|
value={roundForm.name}
|
|
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium">Round Type *</Label>
|
|
<Select
|
|
value={roundForm.roundType}
|
|
onValueChange={(v) => setRoundForm({ ...roundForm, roundType: v })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{ROUND_TYPES.map((rt) => {
|
|
const colors = ROUND_TYPE_COLORS[rt.value]
|
|
return (
|
|
<SelectItem key={rt.value} value={rt.value}>
|
|
<span className="flex items-center gap-2">
|
|
<span
|
|
className="h-2 w-2 rounded-full shrink-0"
|
|
style={{ backgroundColor: colors.dot }}
|
|
/>
|
|
{rt.label}
|
|
</span>
|
|
</SelectItem>
|
|
)
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => setAddRoundOpen(false)}>Cancel</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleCreateRound}
|
|
disabled={createRoundMutation.isPending}
|
|
className="bg-[#de0f1e] hover:bg-[#de0f1e]/90"
|
|
>
|
|
{createRoundMutation.isPending ? 'Creating...' : 'Create Round'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</TooltipProvider>
|
|
)
|
|
}
|
|
|
|
// ─── Award Node ──────────────────────────────────────────────────────────────
|
|
|
|
function AwardNode({ award }: { award: SpecialAwardItem }) {
|
|
const statusColor = AWARD_STATUS_COLORS[award.status] ?? 'text-gray-500'
|
|
const isExclusive = award.eligibilityMode === 'SEPARATE_POOL'
|
|
const eligible = award._count.eligibilities
|
|
const hasWinner = !!award.winnerProject
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={150}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Link href={`/admin/awards/${award.id}` as Route}>
|
|
<div className={cn(
|
|
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border text-xs cursor-pointer transition-all',
|
|
'bg-amber-50/60 border-amber-200/80 hover:border-amber-400 hover:shadow-sm',
|
|
hasWinner && 'bg-emerald-50/60 border-emerald-200/80 hover:border-emerald-400',
|
|
)}>
|
|
<Trophy className={cn('h-3 w-3 shrink-0', hasWinner ? 'text-emerald-600' : 'text-amber-500')} />
|
|
<span className="font-medium text-[#053d57] truncate max-w-[140px]">
|
|
{award.name}
|
|
</span>
|
|
{eligible > 0 && (
|
|
<span className="text-muted-foreground tabular-nums">{eligible}</span>
|
|
)}
|
|
<span className={cn(
|
|
'text-[9px] font-semibold uppercase tracking-wide px-1 py-px rounded',
|
|
isExclusive
|
|
? 'bg-red-100 text-red-600'
|
|
: 'bg-blue-100 text-blue-600'
|
|
)}>
|
|
{isExclusive ? 'Excl' : 'Par'}
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="max-w-xs text-xs">
|
|
<p className="font-semibold">{award.name}</p>
|
|
<p className="text-muted-foreground mt-0.5">
|
|
{isExclusive ? 'Exclusive pool (projects leave main track)' : 'Parallel (projects stay in main track)'}
|
|
</p>
|
|
<p className="mt-1">
|
|
{eligible} eligible · {award._count.jurors} jurors · {award._count.votes} votes
|
|
</p>
|
|
{hasWinner && (
|
|
<p className="text-emerald-600 font-medium mt-1">
|
|
Winner: {award.winnerProject!.title}
|
|
</p>
|
|
)}
|
|
<p className={cn('mt-1', statusColor)}>
|
|
Status: {award.status.replace('_', ' ')}
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)
|
|
}
|