'use client' import { useState, useEffect, useCallback } from 'react' import Link from 'next/link' import { useSearchParams, usePathname } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { Progress } from '@/components/ui/progress' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Plus, MoreHorizontal, ClipboardList, Eye, Pencil, FileUp, Users, Search, Trash2, Loader2, Sparkles, Tags, Clock, CheckCircle2, AlertCircle, Layers, FolderOpen, X, AlertTriangle, ArrowRightCircle, LayoutGrid, LayoutList, } from 'lucide-react' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Checkbox } from '@/components/ui/checkbox' import { Label } from '@/components/ui/label' import { truncate } from '@/lib/utils' import { ProjectLogo } from '@/components/shared/project-logo' import { StatusBadge } from '@/components/shared/status-badge' import { Pagination } from '@/components/shared/pagination' import { ProjectFiltersBar, type ProjectFilters, } from './project-filters' const statusColors: Record< string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning' > = { SUBMITTED: 'secondary', ELIGIBLE: 'default', ASSIGNED: 'default', SEMIFINALIST: 'success', FINALIST: 'success', WINNER: 'success', REJECTED: 'destructive', WITHDRAWN: 'secondary', } function parseFiltersFromParams( searchParams: URLSearchParams ): ProjectFilters & { page: number; perPage: number } { return { search: searchParams.get('q') || '', statuses: searchParams.get('status') ? searchParams.get('status')!.split(',') : [], roundId: searchParams.get('round') || '', competitionCategory: searchParams.get('category') || '', oceanIssue: searchParams.get('issue') || '', country: searchParams.get('country') || '', wantsMentorship: searchParams.get('mentorship') === 'true' ? true : searchParams.get('mentorship') === 'false' ? false : undefined, hasFiles: searchParams.get('hasFiles') === 'true' ? true : searchParams.get('hasFiles') === 'false' ? false : undefined, hasAssignments: searchParams.get('hasAssign') === 'true' ? true : searchParams.get('hasAssign') === 'false' ? false : undefined, page: parseInt(searchParams.get('page') || '1', 10), perPage: parseInt(searchParams.get('pp') || '20', 10), } } function filtersToParams( filters: ProjectFilters & { page: number; perPage: number } ): URLSearchParams { const params = new URLSearchParams() if (filters.search) params.set('q', filters.search) if (filters.statuses.length > 0) params.set('status', filters.statuses.join(',')) if (filters.roundId) params.set('round', filters.roundId) if (filters.competitionCategory) params.set('category', filters.competitionCategory) if (filters.oceanIssue) params.set('issue', filters.oceanIssue) if (filters.country) params.set('country', filters.country) if (filters.wantsMentorship !== undefined) params.set('mentorship', String(filters.wantsMentorship)) if (filters.hasFiles !== undefined) params.set('hasFiles', String(filters.hasFiles)) if (filters.hasAssignments !== undefined) params.set('hasAssign', String(filters.hasAssignments)) if (filters.page > 1) params.set('page', String(filters.page)) if (filters.perPage !== 20) params.set('pp', String(filters.perPage)) return params } export default function ProjectsPage() { const pathname = usePathname() const searchParams = useSearchParams() const parsed = parseFiltersFromParams(searchParams) const [filters, setFilters] = useState({ search: parsed.search, statuses: parsed.statuses, roundId: parsed.roundId, competitionCategory: parsed.competitionCategory, oceanIssue: parsed.oceanIssue, country: parsed.country, wantsMentorship: parsed.wantsMentorship, hasFiles: parsed.hasFiles, hasAssignments: parsed.hasAssignments, }) const [page, setPage] = useState(parsed.page) const [perPage, setPerPage] = useState(parsed.perPage || 20) const [searchInput, setSearchInput] = useState(parsed.search) const [viewMode, setViewMode] = useState<'table' | 'card'>('table') // Fetch display settings const { data: displaySettings } = trpc.settings.getMultiple.useQuery({ keys: ['display_project_names_uppercase'], }) const uppercaseNames = displaySettings?.find( (s: { key: string; value: string }) => s.key === 'display_project_names_uppercase' )?.value !== 'false' // Debounced search useEffect(() => { const timer = setTimeout(() => { if (searchInput !== filters.search) { setFilters((f) => ({ ...f, search: searchInput })) setPage(1) } }, 300) return () => clearTimeout(timer) }, [searchInput, filters.search]) // Sync URL const syncUrl = useCallback( (f: ProjectFilters, p: number, pp: number) => { const params = filtersToParams({ ...f, page: p, perPage: pp }) const qs = params.toString() window.history.replaceState(null, '', qs ? `${pathname}?${qs}` : pathname) }, [pathname] ) useEffect(() => { syncUrl(filters, page, perPage) }, [filters, page, perPage, syncUrl]) const handlePerPageChange = (newPerPage: number) => { setPerPage(newPerPage) setPage(1) } // Reset page when filters change const handleFiltersChange = (newFilters: ProjectFilters) => { setFilters(newFilters) setPage(1) } // Build tRPC query input const queryInput = { search: filters.search || undefined, statuses: filters.statuses.length > 0 ? (filters.statuses as Array< | 'SUBMITTED' | 'ELIGIBLE' | 'ASSIGNED' | 'SEMIFINALIST' | 'FINALIST' | 'REJECTED' >) : undefined, roundId: filters.roundId || undefined, competitionCategory: (filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') || undefined, oceanIssue: filters.oceanIssue ? (filters.oceanIssue as | 'POLLUTION_REDUCTION' | 'CLIMATE_MITIGATION' | 'TECHNOLOGY_INNOVATION' | 'SUSTAINABLE_SHIPPING' | 'BLUE_CARBON' | 'HABITAT_RESTORATION' | 'COMMUNITY_CAPACITY' | 'SUSTAINABLE_FISHING' | 'CONSUMER_AWARENESS' | 'OCEAN_ACIDIFICATION' | 'OTHER') : undefined, country: filters.country || undefined, wantsMentorship: filters.wantsMentorship, hasFiles: filters.hasFiles, hasAssignments: filters.hasAssignments, page, perPage, } const utils = trpc.useUtils() const { data, isLoading } = trpc.project.list.useQuery(queryInput) const { data: filterOptions } = trpc.project.getFilterOptions.useQuery() const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null) // Assign to round dialog state const [assignDialogOpen, setAssignDialogOpen] = useState(false) const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null) const [assignRoundId, setAssignRoundId] = useState('') const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false) const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round') const [selectedRoundForTagging, setSelectedRoundForTagging] = useState('') const [selectedProgramForTagging, setSelectedProgramForTagging] = useState('') const [activeTaggingJobId, setActiveTaggingJobId] = useState(null) // Fetch programs and rounds for the AI tagging dialog const { data: programs } = trpc.program.list.useQuery() // Start tagging job mutation const startTaggingJob = trpc.tag.startTaggingJob.useMutation({ onSuccess: (result) => { setActiveTaggingJobId(result.jobId) toast.info('AI tagging job started. Progress will update automatically.') }, onError: (error) => { toast.error(error.message || 'Failed to start tagging job') }, }) // Poll for job status when job is active const { data: jobStatus } = trpc.tag.getTaggingJobStatus.useQuery( { jobId: activeTaggingJobId! }, { enabled: !!activeTaggingJobId, refetchInterval: (query) => { const status = query.state.data?.status // Stop polling when job is done if (status === 'COMPLETED' || status === 'FAILED') { return false } return 1500 // Poll every 1.5 seconds }, } ) // Handle job completion useEffect(() => { if (jobStatus?.status === 'COMPLETED') { toast.success( `AI Tagging complete: ${jobStatus.taggedCount} tagged, ${jobStatus.skippedCount} already tagged, ${jobStatus.failedCount} failed` ) utils.project.list.invalidate() } else if (jobStatus?.status === 'FAILED') { toast.error(`AI Tagging failed: ${jobStatus.errorMessage || 'Unknown error'}`) } }, [jobStatus?.status, jobStatus?.taggedCount, jobStatus?.skippedCount, jobStatus?.failedCount, jobStatus?.errorMessage, utils.project.list]) const taggingInProgress = startTaggingJob.isPending || (jobStatus?.status === 'PENDING' || jobStatus?.status === 'RUNNING') const taggingResult = jobStatus?.status === 'COMPLETED' || jobStatus?.status === 'FAILED' ? { processed: jobStatus.taggedCount, skipped: jobStatus.skippedCount, failed: jobStatus.failedCount, errors: jobStatus.errors || [], status: jobStatus.status, } : null const handleStartTagging = () => { if (taggingScope === 'round' && selectedRoundForTagging) { startTaggingJob.mutate({ roundId: selectedRoundForTagging }) } else if (taggingScope === 'program' && selectedProgramForTagging) { startTaggingJob.mutate({ programId: selectedProgramForTagging }) } } const handleCloseTaggingDialog = () => { if (!taggingInProgress) { setAiTagDialogOpen(false) setActiveTaggingJobId(null) setSelectedRoundForTagging('') setSelectedProgramForTagging('') } } // Get selected program's rounds const selectedProgram = programs?.find(p => p.id === selectedProgramForTagging) const programRounds = filterOptions?.rounds?.filter(r => r.program?.id === selectedProgramForTagging) ?? [] // Calculate stats for display const selectedRound = filterOptions?.rounds?.find(r => r.id === selectedRoundForTagging) const displayProgram = taggingScope === 'program' ? selectedProgram : (selectedRound ? programs?.find(p => p.id === selectedRound.program?.id) : null) // Calculate progress percentage const taggingProgressPercent = jobStatus && jobStatus.totalProjects > 0 ? Math.round((jobStatus.processedCount / jobStatus.totalProjects) * 100) : 0 // Bulk selection state const [selectedIds, setSelectedIds] = useState>(new Set()) const [bulkStatus, setBulkStatus] = useState('') const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false) const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status') const [bulkAssignRoundId, setBulkAssignRoundId] = useState('') const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false) const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false) const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({ onSuccess: (result) => { toast.success(`${result.updated} project${result.updated !== 1 ? 's' : ''} updated successfully`) setSelectedIds(new Set()) setBulkStatus('') setBulkConfirmOpen(false) utils.project.list.invalidate() }, onError: (error) => { toast.error(error.message || 'Failed to update projects') }, }) const bulkAssignToRound = trpc.projectPool.assignToRound.useMutation({ onSuccess: (result) => { toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to ${result.roundName}`) setSelectedIds(new Set()) setBulkAssignRoundId('') setBulkAssignDialogOpen(false) utils.project.list.invalidate() }, onError: (error) => { toast.error(error.message || 'Failed to assign projects') }, }) const bulkDeleteProjects = trpc.project.bulkDelete.useMutation({ onSuccess: (result) => { toast.success(`${result.deleted} project${result.deleted !== 1 ? 's' : ''} deleted`) setSelectedIds(new Set()) setBulkDeleteConfirmOpen(false) utils.project.list.invalidate() }, onError: (error) => { toast.error(error.message || 'Failed to delete projects') }, }) const handleToggleSelect = (id: string) => { setSelectedIds((prev) => { const next = new Set(prev) if (next.has(id)) { next.delete(id) } else { next.add(id) } return next }) } const handleSelectAll = () => { if (!data) return const allVisible = data.projects.map((p) => p.id) const allSelected = allVisible.every((id) => selectedIds.has(id)) if (allSelected) { setSelectedIds((prev) => { const next = new Set(prev) allVisible.forEach((id) => next.delete(id)) return next }) } else { setSelectedIds((prev) => { const next = new Set(prev) allVisible.forEach((id) => next.add(id)) return next }) } } const handleBulkApply = () => { if (!bulkStatus || selectedIds.size === 0) return setBulkConfirmOpen(true) } const handleBulkConfirm = () => { if (!bulkStatus || selectedIds.size === 0 || !filters.roundId) return bulkUpdateStatus.mutate({ ids: Array.from(selectedIds), roundId: filters.roundId, status: bulkStatus as 'SUBMITTED' | 'ELIGIBLE' | 'ASSIGNED' | 'SEMIFINALIST' | 'FINALIST' | 'REJECTED', }) } // Determine if all visible projects are selected const allVisibleSelected = data ? data.projects.length > 0 && data.projects.every((p) => selectedIds.has(p.id)) : false const someVisibleSelected = data ? data.projects.some((p) => selectedIds.has(p.id)) && !allVisibleSelected : false const assignToRound = trpc.projectPool.assignToRound.useMutation({ onSuccess: () => { toast.success('Project assigned to round') utils.project.list.invalidate() setAssignDialogOpen(false) setProjectToAssign(null) setAssignRoundId('') }, onError: (error) => { toast.error(error.message || 'Failed to assign project') }, }) const deleteProject = trpc.project.delete.useMutation({ onSuccess: () => { toast.success('Project deleted successfully') utils.project.list.invalidate() setDeleteDialogOpen(false) setProjectToDelete(null) }, onError: (error) => { toast.error(error.message || 'Failed to delete project') }, }) const handleDeleteClick = (project: { id: string; title: string }) => { setProjectToDelete(project) setDeleteDialogOpen(true) } return (
{/* Header */}

Projects

{data ? `${data.total} projects across all rounds` : 'Manage submitted projects across all rounds'}

{/* Search */}
setSearchInput(e.target.value)} placeholder="Search projects by title, team, or description..." className="pl-10 pr-10" /> {searchInput && ( )}
{filters.search && data && (

{data.total} result{data.total !== 1 ? 's' : ''} for “{filters.search}”

)}
{/* Filters */} {/* Stats Summary + View Toggle */} {data && data.projects.length > 0 && (
{Object.entries( data.projects.reduce>((acc, p) => { const s = p.status ?? 'SUBMITTED' acc[s] = (acc[s] || 0) + 1 return acc }, {}) ) .sort(([a], [b]) => { const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN'] return order.indexOf(a) - order.indexOf(b) }) .map(([status, count]) => ( {count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')} ))} {data.total > data.projects.length && ( (page {data.page} of {data.totalPages}) )}
)} {/* Content */} {isLoading ? (
{[...Array(5)].map((_, i) => (
))}
) : data && data.projects.length === 0 ? (

No projects found

{filters.search || filters.statuses.length > 0 || filters.roundId || filters.competitionCategory || filters.oceanIssue || filters.country ? 'Try adjusting your filters' : 'Import projects via CSV or create them manually'}

{!filters.search && filters.statuses.length === 0 && (
)}
) : data ? ( <> {/* Table View */} {viewMode === 'table' ? ( <> {/* Desktop table */} Project Category Round Tags Assignments Status Actions {data.projects.map((project) => { const isEliminated = project.status === 'REJECTED' return ( handleToggleSelect(project.id)} aria-label={`Select ${project.title}`} onClick={(e) => e.stopPropagation()} />

{truncate(project.title, 40)}

{project.teamName} {project.country && ( · {project.country} )}

{project.competitionCategory ? ( {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} ) : ( - )}
{project.round ? (

{project.round.name}

) : ( Unassigned )}

{project.round?.program?.name}

{project.tags && project.tags.length > 0 ? (
{project.tags.slice(0, 3).map((tag) => ( {tag} ))} {project.tags.length > 3 && ( +{project.tags.length - 3} )}
) : ( - )}
{project._count.assignments}
View Details Edit {!project.round && ( { e.stopPropagation() setProjectToAssign({ id: project.id, title: project.title }) setAssignDialogOpen(true) }} > Assign to Round )} { e.stopPropagation() handleDeleteClick({ id: project.id, title: project.title }) }} > Delete
)})}
{/* Mobile card view (table mode fallback) */}
{data.projects.map((project) => (
handleToggleSelect(project.id)} aria-label={`Select ${project.title}`} />
{project.title}
{project.teamName}
Round {project.round?.name ?? 'Unassigned'}
{project.competitionCategory && (
Category {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
)}
Assignments {project._count.assignments} jurors
{project.tags && project.tags.length > 0 && (
{project.tags.slice(0, 4).map((tag) => ( {tag} ))} {project.tags.length > 4 && ( +{project.tags.length - 4} )}
)}
))}
) : ( /* Card View */
{data.projects.map((project) => { const isEliminated = project.status === 'REJECTED' return (
handleToggleSelect(project.id)} aria-label={`Select ${project.title}`} />
{project.title} View Details Edit {!project.round && ( { e.stopPropagation() setProjectToAssign({ id: project.id, title: project.title }) setAssignDialogOpen(true) }} > Assign to Round )} { e.stopPropagation() handleDeleteClick({ id: project.id, title: project.title }) }} > Delete
{project.teamName} {project.country && ( · {project.country} )}
{project.competitionCategory && ( {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} )}
Round {project.round ? ( <>{project.round.name} ) : ( Unassigned )}
Jurors {project._count.assignments}
Files {project._count?.files ?? 0}
{project.createdAt && (
Submitted {new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}
)} {project.tags && project.tags.length > 0 && (
{project.tags.slice(0, 5).map((tag) => ( {tag} ))} {project.tags.length > 5 && ( +{project.tags.length - 5} )}
)}
) })}
)} {/* Pagination */} ) : null} {/* Bulk Action Floating Toolbar */} {selectedIds.size > 0 && (
{selectedIds.size} selected
{/* Assign to Round */} {/* Change Status (only when filtered by round) */} {filters.roundId && ( <> )} {/* Delete */}
)} {/* Bulk Status Update Confirmation Dialog */} Update Project Status

You are about to change the status of{' '} {selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''}{' '} to {bulkStatus.replace('_', ' ')}.

{bulkStatus === 'REJECTED' && (

Warning: Rejected projects will be marked as eliminated. This will send notifications to the project teams.

)}
Cancel {bulkUpdateStatus.isPending ? ( ) : null} Update {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
{/* Delete Confirmation Dialog */} Delete Project Are you sure you want to delete "{projectToDelete?.title}"? This will permanently remove the project, all its files, assignments, and evaluations. This action cannot be undone. Cancel projectToDelete && deleteProject.mutate({ id: projectToDelete.id })} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > {deleteProject.isPending ? ( ) : null} Delete {/* Assign to Round Dialog */} { setAssignDialogOpen(open) if (!open) { setProjectToAssign(null); setAssignRoundId('') } }}> Assign to Round Assign "{projectToAssign?.title}" to a round.
{/* Bulk Assign to Round Dialog */} { setBulkAssignDialogOpen(open) if (!open) setBulkAssignRoundId('') }}> Assign to Round Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a round. Projects will have their status set to "Assigned".
{/* Bulk Delete Confirmation Dialog */} Delete {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}

Are you sure you want to permanently delete{' '} {selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''}? This will remove all associated files, assignments, and evaluations.

This action cannot be undone. All project data will be permanently lost.

Cancel { bulkDeleteProjects.mutate({ ids: Array.from(selectedIds) }) }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={bulkDeleteProjects.isPending} > {bulkDeleteProjects.isPending ? ( ) : null} Delete {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
{/* AI Tagging Dialog */}
AI Tag Generator

Automatically categorize projects with expertise tags

{/* Progress Indicator (when running) */} {taggingInProgress && (

AI Tagging in Progress

{jobStatus?.status === 'PENDING' ? 'Initializing...' : `Processing ${jobStatus?.totalProjects || 0} projects with AI...`}

{jobStatus && jobStatus.totalProjects > 0 && ( {jobStatus.processedCount} / {jobStatus.totalProjects} )}
{jobStatus?.processedCount || 0} of {jobStatus?.totalProjects || '?'} projects processed {jobStatus?.taggedCount ? ` (${jobStatus.taggedCount} tagged)` : ''} {jobStatus && jobStatus.totalProjects > 0 && ( {taggingProgressPercent}% )}
{jobStatus?.failedCount ? (

{jobStatus.failedCount} projects failed so far

) : null}
)} {/* Result Display */} {taggingResult && !taggingInProgress && (
0 ? 'bg-amber-50 dark:bg-amber-950/20 border-amber-200 dark:border-amber-900' : taggingResult.processed > 0 ? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900' : 'bg-muted border-border' }`}>
{taggingResult.processed > 0 ? ( ) : taggingResult.errors.length > 0 ? ( ) : ( )} {taggingResult.processed > 0 ? 'Tagging Complete' : taggingResult.errors.length > 0 ? 'Tagging Issue' : 'No Projects to Tag'}

{taggingResult.processed}

Tagged

{taggingResult.skipped}

Already Tagged

{taggingResult.failed}

Failed

{taggingResult.errors.length > 0 && (

{taggingResult.errors.length} project{taggingResult.errors.length > 1 ? 's' : ''} failed:

{taggingResult.errors.map((error, i) => (

• {error}

))}
)}
)} {/* Scope Selection */} {!taggingInProgress && !taggingResult && ( <>
{/* Selection */}
{taggingScope === 'round' ? ( <> ) : ( <> )}
{/* Info Note */}

Only projects without existing tags will be processed. AI will analyze project descriptions and assign relevant expertise tags for jury matching.

)}
{/* Footer */}
{taggingResult ? ( ) : ( <> )}
) }