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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user