'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 { Label } from '@/components/ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { ScrollArea } from '@/components/ui/scroll-area' 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, Sparkles, Users, } 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 onAssignProjects?: (projectIds: string[]) => void } export function ProjectStatesTable({ competitionId, roundId, onAssignProjects }: 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 [addProjectOpen, setAddProjectOpen] = useState(false) const utils = trpc.useUtils() const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` 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 tagProject = trpc.tag.tagProject.useMutation({ onSuccess: () => { toast.success('AI tags generated') utils.roundEngine.getProjectStates.invalidate({ roundId }) }, onError: (err: any) => toast.error(`Tag generation failed: ${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) => { const p = ps.project return ( (p?.title || '').toLowerCase().includes(q) || (p?.teamName || '').toLowerCase().includes(q) || (p?.country || '').toLowerCase().includes(q) || (p?.institution || '').toLowerCase().includes(q) || (p?.competitionCategory || '').toLowerCase().includes(q) || (p?.geographicZone || '').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
{onAssignProjects && ( )}
)} {/* Table */}
{/* Header */}
0 && filtered.every((ps: any) => selectedIds.has(ps.projectId))} onCheckedChange={toggleSelectAll} />
Project
Category
Country
State
Reviews
Entered
{/* Rows */} {filtered.map((ps: any) => { const cfg = stateConfig[ps.state as ProjectState] || stateConfig.PENDING const StateIcon = cfg.icon const total = ps.totalAssignments ?? 0 const submitted = ps.submittedCount ?? 0 const allDone = total > 0 && submitted === total return (
toggleSelect(ps.projectId)} />
{ps.project?.title || 'Unknown'}

{ps.project?.teamName}

{ps.project?.competitionCategory || '—'}
{ps.project?.country || '—'}
{cfg.label}
{total > 0 ? ( {submitted}/{total} ) : ( )}
{ps.enteredAt ? new Date(ps.enteredAt).toLocaleDateString() : '—'}
View Project { e.stopPropagation() tagProject.mutate({ projectId: ps.projectId }) }} disabled={tagProject.isPending} > Generate AI Tags {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 (legacy, kept for empty state) */} { utils.roundEngine.getProjectStates.invalidate({ roundId }) }} /> {/* Add Project Dialog (Create New + From Pool) */} { 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

)}
) } /** * Add Project Dialog — two tabs: "Create New" and "From Pool". * Create New: form to create a project and assign it directly to the round. * From Pool: search existing projects not yet in this round and assign them. */ function AddProjectDialog({ open, onOpenChange, roundId, competitionId, onAssigned, }: { open: boolean onOpenChange: (open: boolean) => void roundId: string competitionId: string onAssigned: () => void }) { const [activeTab, setActiveTab] = useState<'create' | 'pool'>('create') // ── Create New tab state ── const [title, setTitle] = useState('') const [teamName, setTeamName] = useState('') const [description, setDescription] = useState('') const [country, setCountry] = useState('') const [category, setCategory] = useState('') // ── From Pool tab state ── const [poolSearch, setPoolSearch] = useState('') const [selectedPoolIds, setSelectedPoolIds] = useState>(new Set()) const utils = trpc.useUtils() // Get the competition to find programId (for pool search) const { data: competition } = trpc.competition.getById.useQuery( { id: competitionId }, { enabled: open && !!competitionId }, ) const programId = (competition as any)?.programId || '' // Pool query const { data: poolResults, isLoading: poolLoading } = trpc.projectPool.listUnassigned.useQuery( { programId, excludeRoundId: roundId, search: poolSearch.trim() || undefined, perPage: 50, }, { enabled: open && activeTab === 'pool' && !!programId }, ) // Create mutation const createMutation = trpc.project.createAndAssignToRound.useMutation({ onSuccess: () => { toast.success('Project created and added to round') utils.roundEngine.getProjectStates.invalidate({ roundId }) onAssigned() resetAndClose() }, onError: (err) => toast.error(err.message), }) // Assign from pool mutation const assignMutation = trpc.projectPool.assignToRound.useMutation({ onSuccess: (data) => { toast.success(`${data.assignedCount} project(s) added to round`) utils.roundEngine.getProjectStates.invalidate({ roundId }) onAssigned() resetAndClose() }, onError: (err) => toast.error(err.message), }) const resetAndClose = () => { setTitle('') setTeamName('') setDescription('') setCountry('') setCategory('') setPoolSearch('') setSelectedPoolIds(new Set()) onOpenChange(false) } const handleCreate = () => { if (!title.trim()) return createMutation.mutate({ title: title.trim(), teamName: teamName.trim() || undefined, description: description.trim() || undefined, country: country.trim() || undefined, competitionCategory: category === 'STARTUP' || category === 'BUSINESS_CONCEPT' ? category : undefined, roundId, }) } const handleAssignFromPool = () => { if (selectedPoolIds.size === 0) return assignMutation.mutate({ projectIds: Array.from(selectedPoolIds), roundId, }) } const togglePoolProject = (id: string) => { setSelectedPoolIds(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } const isMutating = createMutation.isPending || assignMutation.isPending return ( { if (!isOpen) resetAndClose() else onOpenChange(true) }}> Add Project to Round Create a new project or select existing ones to add to this round. setActiveTab(v as 'create' | 'pool')}> Create New From Pool {/* ── Create New Tab ── */}
setTitle(e.target.value)} />
setTeamName(e.target.value)} />
setCountry(e.target.value)} />
setDescription(e.target.value)} />
{/* ── From Pool Tab ── */}
setPoolSearch(e.target.value)} className="pl-8" />
{poolLoading && (
)} {!poolLoading && poolResults?.projects.length === 0 && (

{poolSearch.trim() ? `No projects found matching "${poolSearch}"` : 'No projects available to add'}

)} {poolResults?.projects.map((project: any) => { const isSelected = selectedPoolIds.has(project.id) return ( ) })}
{poolResults && poolResults.total > 50 && (

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

)}
) }