'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 CompetitionRound = { id: string name: string sortOrder: number _count: { projectRoundStates: number } } type ProjectStatesTableProps = { competitionId: string roundId: string roundStatus?: string competitionRounds?: CompetitionRound[] currentSortOrder?: number onAssignProjects?: (projectIds: string[]) => void } export function ProjectStatesTable({ competitionId, roundId, roundStatus, competitionRounds, currentSortOrder, 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) => ( ))}
) } const hasEarlierRounds = competitionRounds && currentSortOrder != null && competitionRounds.some((r) => r.sortOrder < currentSortOrder && r._count.projectRoundStates > 0) if (!projectStates || projectStates.length === 0) { return ( <>

No Projects in This Round

Assign projects from the Project Pool or import from an earlier round to get started.

{hasEarlierRounds && ( )}
{ utils.roundEngine.getProjectStates.invalidate({ roundId }) }} /> ) } return (
{/* Finalization hint for closed rounds */} {(roundStatus === 'ROUND_CLOSED' || roundStatus === 'ROUND_ARCHIVED') && (
This round is closed. Use the Finalization tab to review proposed outcomes and confirm advancement.
)} {/* 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 + From Round) */} { 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, competitionRounds, currentSortOrder, defaultTab, onAssigned, }: { open: boolean onOpenChange: (open: boolean) => void roundId: string competitionId: string competitionRounds?: CompetitionRound[] currentSortOrder?: number defaultTab?: 'create' | 'pool' | 'round' onAssigned: () => void }) { const [activeTab, setActiveTab] = useState<'create' | 'pool' | 'round'>(defaultTab ?? '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()) // ── From Round tab state ── const [sourceRoundId, setSourceRoundId] = useState('') const [roundStateFilter, setRoundStateFilter] = useState([]) const [roundSearch, setRoundSearch] = useState('') const [selectedRoundIds, setSelectedRoundIds] = 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 || '' // Earlier rounds available for import const earlierRounds = useMemo(() => { if (!competitionRounds || currentSortOrder == null) return [] return competitionRounds .filter((r) => r.sortOrder < currentSortOrder && r._count.projectRoundStates > 0) }, [competitionRounds, currentSortOrder]) // From Round query const { data: roundProjects, isLoading: roundLoading } = trpc.projectPool.getProjectsInRound.useQuery( { roundId: sourceRoundId, states: roundStateFilter.length > 0 ? roundStateFilter : undefined, search: roundSearch.trim() || undefined, }, { enabled: open && activeTab === 'round' && !!sourceRoundId }, ) // Import mutation const importMutation = trpc.projectPool.importFromRound.useMutation({ onSuccess: (data) => { toast.success(`${data.imported} project(s) imported${data.skipped > 0 ? `, ${data.skipped} already in round` : ''}`) utils.roundEngine.getProjectStates.invalidate({ roundId }) onAssigned() resetAndClose() }, onError: (err) => toast.error(err.message), }) // 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()) setSourceRoundId('') setRoundStateFilter([]) setRoundSearch('') setSelectedRoundIds(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 handleImportFromRound = () => { if (selectedRoundIds.size === 0 || !sourceRoundId) return importMutation.mutate({ sourceRoundId, targetRoundId: roundId, projectIds: Array.from(selectedRoundIds), }) } const toggleRoundProject = (id: string) => { setSelectedRoundIds(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } 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 || importMutation.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' | 'round')}> 0 ? 'grid-cols-3' : 'grid-cols-2'}`}> Create New From Pool {earlierRounds.length > 0 && ( From Round )} {/* ── 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

)}
{/* ── From Round Tab ── */} {earlierRounds.length > 0 && (
{sourceRoundId && ( <>
setRoundSearch(e.target.value)} className="pl-8" />
{['PASSED', 'COMPLETED', 'PENDING', 'IN_PROGRESS', 'REJECTED'].map((state) => { const isActive = roundStateFilter.includes(state) return ( ) })}
{roundLoading && (
)} {!roundLoading && roundProjects?.length === 0 && (

{roundSearch.trim() ? `No projects found matching "${roundSearch}"` : 'No projects in this round'}

)} {roundProjects && roundProjects.length > 0 && ( )} {roundProjects?.map((project) => { const isSelected = selectedRoundIds.has(project.id) return ( ) })}
)}
)}
) }