Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards

- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 16:58:29 +01:00
parent 8fda8deded
commit 90e3adfab2
44 changed files with 7268 additions and 2154 deletions

View File

@@ -267,6 +267,15 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</div>
</div>
)}
{project.foundedAt && (
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Founded</p>
<p className="text-sm">{formatDateOnly(project.foundedAt)}</p>
</div>
</div>
)}
</div>
{/* Submission URLs */}

View File

@@ -1,8 +1,9 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { prisma } from '@/lib/prisma'
'use client'
export const dynamic = 'force-dynamic'
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,
@@ -12,6 +13,7 @@ import {
} 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,
@@ -35,236 +37,182 @@ import {
Pencil,
FileUp,
Users,
Search,
} from 'lucide-react'
import { formatDateOnly, truncate } from '@/lib/utils'
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'
async function ProjectsContent() {
const projects = await prisma.project.findMany({
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
select: {
id: true,
title: true,
teamName: true,
status: true,
logoKey: true,
createdAt: true,
round: {
select: {
id: true,
name: true,
status: true,
program: {
select: {
name: true,
},
},
},
},
_count: {
select: {
assignments: true,
files: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 100,
})
if (projects.length === 0) {
return (
<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 yet</p>
<p className="text-sm text-muted-foreground">
Import projects via CSV or create them manually
</p>
<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>
)
}
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
SUBMITTED: 'secondary',
UNDER_REVIEW: 'default',
SHORTLISTED: 'success',
FINALIST: 'success',
WINNER: 'success',
REJECTED: 'destructive',
WITHDRAWN: 'secondary',
}
return (
<>
{/* Desktop table view */}
<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>
{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._count.files}</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">
{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>
</>
)
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 ProjectsSkeleton() {
return (
<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>
)
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 */}
@@ -291,10 +239,234 @@ export default function ProjectsPage() {
</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 */}
<Suspense fallback={<ProjectsSkeleton />}>
<ProjectsContent />
</Suspense>
{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>
)
}

View File

@@ -0,0 +1,336 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { ChevronDown, Filter, X } from 'lucide-react'
import { cn } from '@/lib/utils'
const ALL_STATUSES = [
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
] as const
const STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
ELIGIBLE: 'bg-blue-100 text-blue-700 hover:bg-blue-200',
ASSIGNED: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200',
SEMIFINALIST: 'bg-green-100 text-green-700 hover:bg-green-200',
FINALIST: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200',
REJECTED: 'bg-red-100 text-red-700 hover:bg-red-200',
}
const ISSUE_LABELS: Record<string, string> = {
POLLUTION_REDUCTION: 'Pollution Reduction',
CLIMATE_MITIGATION: 'Climate Mitigation',
TECHNOLOGY_INNOVATION: 'Technology Innovation',
SUSTAINABLE_SHIPPING: 'Sustainable Shipping',
BLUE_CARBON: 'Blue Carbon',
HABITAT_RESTORATION: 'Habitat Restoration',
COMMUNITY_CAPACITY: 'Community Capacity',
SUSTAINABLE_FISHING: 'Sustainable Fishing',
CONSUMER_AWARENESS: 'Consumer Awareness',
OCEAN_ACIDIFICATION: 'Ocean Acidification',
OTHER: 'Other',
}
export interface ProjectFilters {
search: string
statuses: string[]
roundId: string
competitionCategory: string
oceanIssue: string
country: string
wantsMentorship: boolean | undefined
hasFiles: boolean | undefined
hasAssignments: boolean | undefined
}
interface FilterOptions {
rounds: Array<{ id: string; name: string; program: { name: string } }>
countries: string[]
categories: Array<{ value: string; count: number }>
issues: Array<{ value: string; count: number }>
}
interface ProjectFiltersBarProps {
filters: ProjectFilters
filterOptions: FilterOptions | undefined
onChange: (filters: ProjectFilters) => void
}
export function ProjectFiltersBar({
filters,
filterOptions,
onChange,
}: ProjectFiltersBarProps) {
const [isOpen, setIsOpen] = useState(false)
const activeFilterCount = [
filters.statuses.length > 0,
filters.roundId !== '',
filters.competitionCategory !== '',
filters.oceanIssue !== '',
filters.country !== '',
filters.wantsMentorship !== undefined,
filters.hasFiles !== undefined,
filters.hasAssignments !== undefined,
].filter(Boolean).length
const toggleStatus = (status: string) => {
const next = filters.statuses.includes(status)
? filters.statuses.filter((s) => s !== status)
: [...filters.statuses, status]
onChange({ ...filters, statuses: next })
}
const clearAll = () => {
onChange({
search: filters.search,
statuses: [],
roundId: '',
competitionCategory: '',
oceanIssue: '',
country: '',
wantsMentorship: undefined,
hasFiles: undefined,
hasAssignments: undefined,
})
}
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Filter className="h-4 w-4" />
Filters
{activeFilterCount > 0 && (
<Badge variant="secondary" className="ml-1">
{activeFilterCount}
</Badge>
)}
</CardTitle>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isOpen && 'rotate-180'
)}
/>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 pt-0">
{/* Status toggles */}
<div className="space-y-2">
<Label className="text-sm font-medium">Status</Label>
<div className="flex flex-wrap gap-2">
{ALL_STATUSES.map((status) => (
<button
key={status}
type="button"
onClick={() => toggleStatus(status)}
className={cn(
'rounded-full px-3 py-1 text-xs font-medium transition-colors',
filters.statuses.includes(status)
? STATUS_COLORS[status]
: 'bg-muted text-muted-foreground hover:bg-muted/80'
)}
>
{status.replace('_', ' ')}
</button>
))}
</div>
</div>
{/* Select filters grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label className="text-sm">Round / Edition</Label>
<Select
value={filters.roundId || '_all'}
onValueChange={(v) =>
onChange({ ...filters, roundId: v === '_all' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All rounds" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All rounds</SelectItem>
{filterOptions?.rounds.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name} ({r.program.name})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Category</Label>
<Select
value={filters.competitionCategory || '_all'}
onValueChange={(v) =>
onChange({
...filters,
competitionCategory: v === '_all' ? '' : v,
})
}
>
<SelectTrigger>
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All categories</SelectItem>
{filterOptions?.categories.map((c) => (
<SelectItem key={c.value} value={c.value}>
{c.value.replace('_', ' ')} ({c.count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Ocean Issue</Label>
<Select
value={filters.oceanIssue || '_all'}
onValueChange={(v) =>
onChange({ ...filters, oceanIssue: v === '_all' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All issues" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All issues</SelectItem>
{filterOptions?.issues.map((i) => (
<SelectItem key={i.value} value={i.value}>
{ISSUE_LABELS[i.value] || i.value} ({i.count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Country</Label>
<Select
value={filters.country || '_all'}
onValueChange={(v) =>
onChange({ ...filters, country: v === '_all' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All countries" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All countries</SelectItem>
{filterOptions?.countries.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Boolean toggles */}
<div className="flex flex-wrap gap-6">
<div className="flex items-center gap-2">
<Switch
id="hasFiles"
checked={filters.hasFiles === true}
onCheckedChange={(checked) =>
onChange({
...filters,
hasFiles: checked ? true : undefined,
})
}
/>
<Label htmlFor="hasFiles" className="text-sm">
Has Documents
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="hasAssignments"
checked={filters.hasAssignments === true}
onCheckedChange={(checked) =>
onChange({
...filters,
hasAssignments: checked ? true : undefined,
})
}
/>
<Label htmlFor="hasAssignments" className="text-sm">
Has Assignments
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="wantsMentorship"
checked={filters.wantsMentorship === true}
onCheckedChange={(checked) =>
onChange({
...filters,
wantsMentorship: checked ? true : undefined,
})
}
/>
<Label htmlFor="wantsMentorship" className="text-sm">
Wants Mentorship
</Label>
</div>
</div>
{/* Clear all */}
{activeFilterCount > 0 && (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={clearAll}
className="text-muted-foreground"
>
<X className="mr-1 h-3 w-3" />
Clear All Filters
</Button>
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
}