'use client' import { useState, useCallback, useMemo } from 'react' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' import { Skeleton } from '@/components/ui/skeleton' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Loader2, MoreHorizontal, ArrowRight, XCircle, CheckCircle2, Clock, Play, LogOut, Layers, Trash2, Plus, Search, ExternalLink, } from 'lucide-react' import Link from 'next/link' import type { Route } from 'next' const PROJECT_STATES = ['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'] as const type ProjectState = (typeof PROJECT_STATES)[number] const stateConfig: Record = { PENDING: { label: 'Pending', color: 'bg-gray-100 text-gray-700 border-gray-200', icon: Clock }, IN_PROGRESS: { label: 'In Progress', color: 'bg-blue-100 text-blue-700 border-blue-200', icon: Play }, PASSED: { label: 'Passed', color: 'bg-green-100 text-green-700 border-green-200', icon: CheckCircle2 }, REJECTED: { label: 'Rejected', color: 'bg-red-100 text-red-700 border-red-200', icon: XCircle }, COMPLETED: { label: 'Completed', color: 'bg-emerald-100 text-emerald-700 border-emerald-200', icon: CheckCircle2 }, WITHDRAWN: { label: 'Withdrawn', color: 'bg-orange-100 text-orange-700 border-orange-200', icon: LogOut }, } type ProjectStatesTableProps = { competitionId: string roundId: string } export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTableProps) { const [selectedIds, setSelectedIds] = useState>(new Set()) const [stateFilter, setStateFilter] = useState('ALL') const [searchQuery, setSearchQuery] = useState('') const [batchDialogOpen, setBatchDialogOpen] = useState(false) const [batchNewState, setBatchNewState] = useState('PASSED') const [removeConfirmId, setRemoveConfirmId] = useState(null) const [batchRemoveOpen, setBatchRemoveOpen] = useState(false) const [quickAddOpen, setQuickAddOpen] = useState(false) const utils = trpc.useUtils() const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route const { data: projectStates, isLoading } = trpc.roundEngine.getProjectStates.useQuery( { roundId }, { refetchInterval: 15_000 }, ) const transitionMutation = trpc.roundEngine.transitionProject.useMutation({ onSuccess: () => { utils.roundEngine.getProjectStates.invalidate({ roundId }) toast.success('Project state updated') }, onError: (err) => toast.error(err.message), }) const batchTransitionMutation = trpc.roundEngine.batchTransition.useMutation({ onSuccess: (data) => { utils.roundEngine.getProjectStates.invalidate({ roundId }) setSelectedIds(new Set()) setBatchDialogOpen(false) toast.success(`${data.succeeded.length} projects updated${data.failed.length > 0 ? `, ${data.failed.length} failed` : ''}`) }, onError: (err) => toast.error(err.message), }) const removeMutation = trpc.roundEngine.removeFromRound.useMutation({ onSuccess: (data) => { utils.roundEngine.getProjectStates.invalidate({ roundId }) setRemoveConfirmId(null) toast.success(`Removed from ${data.removedFromRounds} round(s)`) }, onError: (err) => toast.error(err.message), }) const batchRemoveMutation = trpc.roundEngine.batchRemoveFromRound.useMutation({ onSuccess: (data) => { utils.roundEngine.getProjectStates.invalidate({ roundId }) setSelectedIds(new Set()) setBatchRemoveOpen(false) toast.success(`${data.removedCount} project(s) removed from this round and later rounds`) }, onError: (err) => toast.error(err.message), }) const handleTransition = (projectId: string, newState: ProjectState) => { transitionMutation.mutate({ projectId, roundId, newState }) } const handleBatchTransition = () => { batchTransitionMutation.mutate({ projectIds: Array.from(selectedIds), roundId, newState: batchNewState, }) } const toggleSelect = (id: string) => { setSelectedIds((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } // Apply state filter first, then search filter const filtered = useMemo(() => { let result = projectStates ?? [] if (stateFilter !== 'ALL') { result = result.filter((ps: any) => ps.state === stateFilter) } if (searchQuery.trim()) { const q = searchQuery.toLowerCase() result = result.filter((ps: any) => (ps.project?.title || '').toLowerCase().includes(q) || (ps.project?.teamName || '').toLowerCase().includes(q) ) } return result }, [projectStates, stateFilter, searchQuery]) const toggleSelectAll = useCallback(() => { const ids = filtered.map((ps: any) => ps.projectId) const allSelected = ids.length > 0 && ids.every((id: string) => selectedIds.has(id)) if (allSelected) { setSelectedIds((prev) => { const next = new Set(prev) ids.forEach((id: string) => next.delete(id)) return next }) } else { setSelectedIds((prev) => { const next = new Set(prev) ids.forEach((id: string) => next.add(id)) return next }) } }, [filtered, selectedIds]) // State counts const counts = projectStates?.reduce((acc: Record, ps: any) => { acc[ps.state] = (acc[ps.state] || 0) + 1 return acc }, {} as Record) ?? {} if (isLoading) { return (
{[1, 2, 3, 4, 5].map((i) => ( ))}
) } if (!projectStates || projectStates.length === 0) { return (

No Projects in This Round

Assign projects from the Project Pool to this round to get started.

) } return (
{/* Top bar: search + filters + add buttons */}
setSearchQuery(e.target.value)} className="pl-8 h-8 text-sm" />
{PROJECT_STATES.map((state) => { const count = counts[state] || 0 if (count === 0) return null const cfg = stateConfig[state] return ( ) })}
{/* Search results count */} {searchQuery.trim() && (

Showing {filtered.length} of {projectStates.length} projects matching "{searchQuery}"

)} {/* Bulk actions bar */} {selectedIds.size > 0 && (
{selectedIds.size} selected
)} {/* Table */}
{/* Header */}
0 && filtered.every((ps: any) => selectedIds.has(ps.projectId))} onCheckedChange={toggleSelectAll} />
Project
Category
Country
State
Entered
{/* Rows */} {filtered.map((ps: any) => { const cfg = stateConfig[ps.state as ProjectState] || stateConfig.PENDING const StateIcon = cfg.icon return (
toggleSelect(ps.projectId)} />
{ps.project?.title || 'Unknown'}

{ps.project?.teamName}

{ps.project?.competitionCategory || '—'}
{ps.project?.country || '—'}
{cfg.label}
{ps.enteredAt ? new Date(ps.enteredAt).toLocaleDateString() : '—'}
View Project {PROJECT_STATES.filter((s) => s !== ps.state).map((state) => { const sCfg = stateConfig[state] return ( handleTransition(ps.projectId, state)} disabled={transitionMutation.isPending} > Move to {sCfg.label} ) })} setRemoveConfirmId(ps.projectId)} className="text-destructive focus:text-destructive" > Remove from Round
) })} {filtered.length === 0 && searchQuery.trim() && (
No projects match "{searchQuery}"
)}
{/* Quick Add Dialog */} { utils.roundEngine.getProjectStates.invalidate({ roundId }) }} /> {/* Single Remove Confirmation */} { if (!open) setRemoveConfirmId(null) }}> Remove project from this round? The project will be removed from this round and all subsequent rounds. It will remain in any prior rounds it was already assigned to. Cancel { if (removeConfirmId) { removeMutation.mutate({ projectId: removeConfirmId, roundId }) } }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={removeMutation.isPending} > {removeMutation.isPending && } Remove {/* Batch Remove Confirmation */} Remove {selectedIds.size} projects from this round? These projects will be removed from this round and all subsequent rounds in the competition. They will remain in any prior rounds they were already assigned to. Cancel { batchRemoveMutation.mutate({ projectIds: Array.from(selectedIds), roundId, }) }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={batchRemoveMutation.isPending} > {batchRemoveMutation.isPending && } Remove {selectedIds.size} Projects {/* Batch Transition Dialog */} Change State for {selectedIds.size} Projects All selected projects will be moved to the new state.
) } /** * Quick Add Dialog — inline search + assign projects to this round without leaving the page. */ function QuickAddDialog({ open, onOpenChange, roundId, competitionId, onAssigned, }: { open: boolean onOpenChange: (open: boolean) => void roundId: string competitionId: string onAssigned: () => void }) { const [search, setSearch] = useState('') const [addingIds, setAddingIds] = useState>(new Set()) // Get the competition to find programId const { data: competition } = trpc.competition.getById.useQuery( { id: competitionId }, { enabled: open && !!competitionId }, ) const programId = (competition as any)?.programId || '' const { data: poolResults, isLoading } = trpc.projectPool.listUnassigned.useQuery( { programId, excludeRoundId: roundId, search: search.trim() || undefined, perPage: 10, }, { enabled: open && !!programId }, ) const assignMutation = trpc.projectPool.assignToRound.useMutation({ onSuccess: (data) => { toast.success(`Added to round`) onAssigned() // Remove from addingIds setAddingIds(new Set()) }, onError: (err) => toast.error(err.message), }) const handleQuickAssign = (projectId: string) => { setAddingIds((prev) => new Set(prev).add(projectId)) assignMutation.mutate({ projectIds: [projectId], roundId }) } return ( Quick Add Projects Search and assign projects to this round without leaving the page.
setSearch(e.target.value)} className="pl-8" autoFocus />
{isLoading && (
)} {!isLoading && poolResults?.projects.length === 0 && (

{search.trim() ? `No projects found matching "${search}"` : 'No unassigned projects available'}

)} {poolResults?.projects.map((project: any) => (

{project.title}

{project.teamName} {project.competitionCategory && ( <> · {project.competitionCategory} )}

))}
{poolResults && poolResults.total > 10 && (

Showing 10 of {poolResults.total} — refine your search for more specific results

)}
) }