'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, } from 'lucide-react' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Label } from '@/components/ui/label' import { truncate } from '@/lib/utils' import { ProjectLogo } from '@/components/shared/project-logo' 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 } { 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), } } function filtersToParams( filters: ProjectFilters & { page: 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)) return params } const PER_PAGE = 20 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 [searchInput, setSearchInput] = useState(parsed.search) // 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) => { const params = filtersToParams({ ...f, page: p }) const qs = params.toString() window.history.replaceState(null, '', qs ? `${pathname}?${qs}` : pathname) }, [pathname] ) useEffect(() => { syncUrl(filters, page) }, [filters, page, syncUrl]) // 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: PER_PAGE, } 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) 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 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

Manage submitted projects across all rounds

{/* Search */}
setSearchInput(e.target.value)} placeholder="Search projects by title, team, or description..." className="pl-10" />
{/* Filters */} {/* 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 ? ( <> {/* Desktop table */} Project Round Files Assignments Status Actions {data.projects.map((project) => { const isEliminated = project.status === 'REJECTED' return (

{truncate(project.title, 40)}

{project.teamName}

{project.round?.name ?? '-'}

{project.status === 'REJECTED' && ( Eliminated )}

{project.round?.program?.name}

{project._count?.files ?? 0}
{project._count.assignments}
{(project.status ?? 'SUBMITTED').replace('_', ' ')} View Details Edit { e.stopPropagation() handleDeleteClick({ id: project.id, title: project.title }) }} > Delete
)})}
{/* Mobile card view */}
{data.projects.map((project) => (
{project.title} {(project.status ?? 'SUBMITTED').replace('_', ' ')}
{project.teamName}
Round
{project.round?.name ?? '-'} {project.status === 'REJECTED' && ( Eliminated )}
Assignments {project._count.assignments} jurors
))}
{/* Pagination */} ) : null} {/* 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 {/* 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 ? ( ) : ( <> )}
) }