All checks were successful
Build and Push Docker Image / build (push) Successful in 8m43s
- Extract round detail monolith (2900→600 lines) into 13 standalone components - Add shared round/status config (round-config.ts) replacing 4 local copies - Delete 12 legacy competition-scoped pages, merge project pool into projects page - Add round-type-specific dashboard stat panels (submission, mentoring, live final, deliberation, summary) - Add contextual header quick actions based on active round type - Improve pipeline visualization: progress bars, checkmarks, chevron connectors, overflow fix - Add config tab completion dots (green/amber/red) and inline validation warnings - Enhance juries page with round assignments, member avatars, and cap mode badges - Add context-aware project list (recent submissions vs active evaluations) - Move competition settings into Manage Editions page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
745 lines
32 KiB
TypeScript
745 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'
|
|
import {
|
|
roundTypeConfig,
|
|
roundStatusConfig,
|
|
awardStatusConfig,
|
|
ROUND_TYPE_OPTIONS,
|
|
} from '@/lib/round-config'
|
|
|
|
// ─── Constants (derived from shared config) ──────────────────────────────────
|
|
|
|
const ROUND_TYPES = ROUND_TYPE_OPTIONS
|
|
|
|
const ROUND_TYPE_COLORS: Record<string, { dot: string; bg: string; text: string; border: string }> = Object.fromEntries(
|
|
Object.entries(roundTypeConfig).map(([k, v]) => [k, { dot: v.dotColor, bg: v.cardBg, text: v.cardText, border: v.cardBorder }])
|
|
)
|
|
|
|
const ROUND_STATUS_STYLES: Record<string, { color: string; label: string; pulse?: boolean }> = Object.fromEntries(
|
|
Object.entries(roundStatusConfig).map(([k, v]) => [k, { color: v.dotColor, label: v.label, pulse: v.pulse }])
|
|
)
|
|
|
|
const AWARD_STATUS_COLORS: Record<string, string> = Object.fromEntries(
|
|
Object.entries(awardStatusConfig).map(([k, v]) => [k, v.color])
|
|
)
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
type RoundWithStats = {
|
|
id: string
|
|
name: string
|
|
slug: string
|
|
roundType: string
|
|
status: string
|
|
sortOrder: number
|
|
windowOpenAt: string | null
|
|
windowCloseAt: string | null
|
|
specialAwardId: 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 [selectedCompId, setSelectedCompId] = useState<string | null>(null)
|
|
|
|
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
|
|
{ programId: programId! },
|
|
{ enabled: !!programId, refetchInterval: 30_000 }
|
|
)
|
|
|
|
// Auto-select first competition, or use the user's selection
|
|
const comp = competitions?.find((c: any) => c.id === selectedCompId) ?? competitions?.[0]
|
|
|
|
const { data: compDetail, isLoading: isLoadingDetail } = trpc.competition.getById.useQuery(
|
|
{ id: comp?.id! },
|
|
{ enabled: !!comp?.id, refetchInterval: 30_000 }
|
|
)
|
|
|
|
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 = (compDetail?.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 program edition to start building the evaluation pipeline.
|
|
</p>
|
|
<Link href={'/admin/programs' as Route}>
|
|
<Button className="bg-[#de0f1e] hover:bg-[#de0f1e]/90">
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Manage Editions
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Main Render ─────────────────────────────────────────────────────────
|
|
|
|
const activeFilter = filterType !== 'all'
|
|
const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0
|
|
const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[]
|
|
const totalAssignments = allRounds.reduce((s, r) => s + r._count.assignments, 0)
|
|
const activeRound = allRounds.find((r) => r.status === 'ROUND_ACTIVE')
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={200}>
|
|
<div className="space-y-5">
|
|
{/* Competition selector (when multiple exist) */}
|
|
{competitions && competitions.length > 1 && (
|
|
<Select value={comp.id} onValueChange={setSelectedCompId}>
|
|
<SelectTrigger className="w-[280px] mb-4">
|
|
<SelectValue placeholder="Select competition" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{competitions.map((c: any) => (
|
|
<SelectItem key={c.id} value={c.id}>
|
|
{c.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
|
|
{/* ── 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>{allRounds.filter((r) => !r.specialAwardId).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} asgn</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>
|
|
)
|
|
}
|