MOPC-App/src/app/(admin)/admin/projects/page.tsx

473 lines
16 KiB
TypeScript
Raw Normal View History

'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 {
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,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Plus,
MoreHorizontal,
ClipboardList,
Eye,
Pencil,
FileUp,
Users,
Search,
} from 'lucide-react'
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<ProjectFilters>({
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 { data, isLoading } = trpc.project.list.useQuery(queryInput)
const { data: filterOptions } = trpc.project.getFilterOptions.useQuery()
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Projects</h1>
<p className="text-muted-foreground">
Manage submitted projects across all rounds
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href="/admin/projects/import">
<FileUp className="mr-2 h-4 w-4" />
Import
</Link>
</Button>
<Button asChild>
<Link href="/admin/projects/new">
<Plus className="mr-2 h-4 w-4" />
Add Project
</Link>
</Button>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search projects by title, team, or description..."
className="pl-10"
/>
</div>
{/* Filters */}
<ProjectFiltersBar
filters={filters}
filterOptions={filterOptions}
onChange={handleFiltersChange}
/>
{/* Content */}
{isLoading ? (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-64" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-9 w-9" />
</div>
))}
</div>
</CardContent>
</Card>
) : data && data.projects.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No projects found</p>
<p className="text-sm text-muted-foreground">
{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'}
</p>
{!filters.search && filters.statuses.length === 0 && (
<div className="mt-4 flex gap-2">
<Button asChild>
<Link href="/admin/projects/import">
<FileUp className="mr-2 h-4 w-4" />
Import CSV
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/admin/projects/new">
<Plus className="mr-2 h-4 w-4" />
Add Project
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
) : data ? (
<>
{/* Desktop table */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Round</TableHead>
<TableHead>Files</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.projects.map((project) => (
<TableRow
key={project.id}
className="group relative cursor-pointer hover:bg-muted/50"
>
<TableCell>
<Link
href={`/admin/projects/${project.id}`}
className="flex items-center gap-3 after:absolute after:inset-0 after:content-['']"
>
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div>
<p className="font-medium hover:text-primary">
{truncate(project.title, 40)}
</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
</p>
</div>
</Link>
</TableCell>
<TableCell>
<div>
<p>{project.round.name}</p>
<p className="text-sm text-muted-foreground">
{project.round.program?.name}
</p>
</div>
</TableCell>
<TableCell>{project.files?.length ?? 0}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
{project._count.assignments}
</div>
</TableCell>
<TableCell>
<Badge
variant={statusColors[project.status] || 'secondary'}
>
{project.status.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="relative z-10 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/admin/projects/${project.id}/assignments`}
>
<Users className="mr-2 h-4 w-4" />
Manage Assignments
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{data.projects.map((project) => (
<Link
key={project.id}
href={`/admin/projects/${project.id}`}
className="block"
>
<Card className="transition-colors hover:bg-muted/50">
<CardHeader className="pb-3">
<div className="flex items-start gap-3">
<ProjectLogo
project={project}
size="md"
fallback="initials"
/>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base line-clamp-2">
{project.title}
</CardTitle>
<Badge
variant={
statusColors[project.status] || 'secondary'
}
className="shrink-0"
>
{project.status.replace('_', ' ')}
</Badge>
</div>
<CardDescription>{project.teamName}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<span>{project.round.name}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>
<span>{project._count.assignments} jurors</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
{/* Pagination */}
<Pagination
page={data.page}
totalPages={data.totalPages}
total={data.total}
perPage={PER_PAGE}
onPageChange={setPage}
/>
</>
) : null}
</div>
)
}