'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 { 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 { Plus, MoreHorizontal, ClipboardList, Eye, Pencil, FileUp, Users, Search, Trash2, Loader2, Sparkles, } 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 [selectedRoundForTagging, setSelectedRoundForTagging] = useState('') // Fetch rounds for the AI tagging dialog const { data: programs } = trpc.program.list.useQuery() const { data: allRounds } = trpc.round.list.useQuery( { programId: programs?.[0]?.id ?? '' }, { enabled: !!programs?.[0]?.id } ) // AI batch tagging mutation const batchTagProjects = trpc.tag.batchTagProjects.useMutation({ onSuccess: (result) => { if (result.errors && result.errors.length > 0) { // Show first error if there are any toast.error(`AI Tagging issue: ${result.errors[0]}`) } else if (result.processed === 0 && result.skipped === 0 && result.failed === 0) { toast.info('No projects to tag - all projects in this round already have tags') } else { toast.success( `AI Tagging complete: ${result.processed} tagged, ${result.skipped} skipped, ${result.failed} failed` ) } setAiTagDialogOpen(false) setSelectedRoundForTagging('') utils.project.list.invalidate() }, onError: (error) => { toast.error(error.message || 'Failed to generate AI tags') }, }) 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.files?.length ?? 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 */} Generate AI Tags Use AI to automatically generate expertise tags for all projects in a round that don't have tags yet. This helps with jury matching and filtering.

Only projects without existing tags will be processed. Existing tags are preserved.

Cancel { if (selectedRoundForTagging) { batchTagProjects.mutate({ roundId: selectedRoundForTagging }) } }} disabled={!selectedRoundForTagging || batchTagProjects.isPending} > {batchTagProjects.isPending ? ( ) : ( )} {batchTagProjects.isPending ? 'Processing...' : 'Generate Tags'}
) }