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:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
336
src/app/(admin)/admin/projects/project-filters.tsx
Normal file
336
src/app/(admin)/admin/projects/project-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user