feat: fix project status counts, add top pagination and sortable columns
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m39s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m39s
- Status counts now show each project's latest round state only (no more inflated counts from projects passing multiple rounds) - Add pagination controls at top of projects, members, and observer lists - Add sortable column headers to admin projects table (title, category, program, assignments, status) and members table (name, role, status, last login) - Backend: add sortBy/sortDir params to project.list and user.list Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -94,6 +94,7 @@ import { ProjectLogo } from '@/components/shared/project-logo'
|
|||||||
import { BulkNotificationDialog } from '@/components/admin/projects/bulk-notification-dialog'
|
import { BulkNotificationDialog } from '@/components/admin/projects/bulk-notification-dialog'
|
||||||
|
|
||||||
import { Pagination } from '@/components/shared/pagination'
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
|
import { SortableHeader } from '@/components/shared/sortable-header'
|
||||||
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
|
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
|
||||||
import { CountryFlagImg } from '@/components/ui/country-select'
|
import { CountryFlagImg } from '@/components/ui/country-select'
|
||||||
import {
|
import {
|
||||||
@@ -215,6 +216,8 @@ export default function ProjectsPage() {
|
|||||||
const [perPage, setPerPage] = useState(parsed.perPage || 20)
|
const [perPage, setPerPage] = useState(parsed.perPage || 20)
|
||||||
const [searchInput, setSearchInput] = useState(parsed.search)
|
const [searchInput, setSearchInput] = useState(parsed.search)
|
||||||
const [viewMode, setViewMode] = useState<'table' | 'card'>('table')
|
const [viewMode, setViewMode] = useState<'table' | 'card'>('table')
|
||||||
|
const [sortBy, setSortBy] = useState<string | undefined>(undefined)
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
||||||
|
|
||||||
// Fetch display settings
|
// Fetch display settings
|
||||||
const { data: displaySettings } = trpc.settings.getMultiple.useQuery({
|
const { data: displaySettings } = trpc.settings.getMultiple.useQuery({
|
||||||
@@ -260,6 +263,16 @@ export default function ProjectsPage() {
|
|||||||
setPage(1)
|
setPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSort = (column: string) => {
|
||||||
|
if (sortBy === column) {
|
||||||
|
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||||
|
} else {
|
||||||
|
setSortBy(column)
|
||||||
|
setSortDir('asc')
|
||||||
|
}
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Build tRPC query input
|
// Build tRPC query input
|
||||||
const queryInput = {
|
const queryInput = {
|
||||||
search: filters.search || undefined,
|
search: filters.search || undefined,
|
||||||
@@ -298,6 +311,8 @@ export default function ProjectsPage() {
|
|||||||
hasAssignments: filters.hasAssignments,
|
hasAssignments: filters.hasAssignments,
|
||||||
page,
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
|
sortBy: sortBy as 'title' | 'category' | 'program' | 'assignments' | 'status' | 'createdAt' | undefined,
|
||||||
|
sortDir: sortBy ? sortDir : undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
@@ -876,6 +891,17 @@ export default function ProjectsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : data ? (
|
) : data ? (
|
||||||
<>
|
<>
|
||||||
|
{/* Top Pagination */}
|
||||||
|
{data.totalPages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
page={data.page}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
total={data.total}
|
||||||
|
perPage={perPage}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table View */}
|
{/* Table View */}
|
||||||
{viewMode === 'table' ? (
|
{viewMode === 'table' ? (
|
||||||
<>
|
<>
|
||||||
@@ -891,12 +917,12 @@ export default function ProjectsPage() {
|
|||||||
aria-label="Select all projects"
|
aria-label="Select all projects"
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="min-w-[280px]">Project</TableHead>
|
<SortableHeader label="Project" column="title" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} className="min-w-[280px]" />
|
||||||
<TableHead>Category</TableHead>
|
<SortableHeader label="Category" column="category" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||||
<TableHead>Program</TableHead>
|
<SortableHeader label="Program" column="program" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||||
<TableHead>Tags</TableHead>
|
<TableHead>Tags</TableHead>
|
||||||
<TableHead>Assignments</TableHead>
|
<SortableHeader label="Assignments" column="assignments" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||||
<TableHead>Status</TableHead>
|
<SortableHeader label="Status" column="status" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
|
import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
|
||||||
import { Pagination } from '@/components/shared/pagination'
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
|
import { SortableHeader } from '@/components/shared/sortable-header'
|
||||||
import { Plus, Users, Search, Mail, Loader2, X, Send } from 'lucide-react'
|
import { Plus, Users, Search, Mail, Loader2, X, Send } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { formatRelativeTime } from '@/lib/utils'
|
import { formatRelativeTime } from '@/lib/utils'
|
||||||
@@ -138,6 +139,18 @@ export function MembersContent() {
|
|||||||
const roles = TAB_ROLES[tab]
|
const roles = TAB_ROLES[tab]
|
||||||
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [sortBy, setSortBy] = useState<string | undefined>(undefined)
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
|
const handleSort = (column: string) => {
|
||||||
|
if (sortBy === column) {
|
||||||
|
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||||
|
} else {
|
||||||
|
setSortBy(column)
|
||||||
|
setSortDir('asc')
|
||||||
|
}
|
||||||
|
updateParams({ page: '1' })
|
||||||
|
}
|
||||||
|
|
||||||
const { data: currentUser } = trpc.user.me.useQuery()
|
const { data: currentUser } = trpc.user.me.useQuery()
|
||||||
const currentUserRole = currentUser?.role as RoleValue | undefined
|
const currentUserRole = currentUser?.role as RoleValue | undefined
|
||||||
@@ -147,6 +160,8 @@ export function MembersContent() {
|
|||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
page,
|
page,
|
||||||
perPage: 20,
|
perPage: 20,
|
||||||
|
sortBy: sortBy as 'name' | 'email' | 'role' | 'status' | 'lastLoginAt' | 'createdAt' | undefined,
|
||||||
|
sortDir: sortBy ? sortDir : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const invitableIdsQuery = trpc.user.listInvitableIds.useQuery(
|
const invitableIdsQuery = trpc.user.listInvitableIds.useQuery(
|
||||||
@@ -290,6 +305,17 @@ export function MembersContent() {
|
|||||||
<MembersSkeleton />
|
<MembersSkeleton />
|
||||||
) : data && data.users.length > 0 ? (
|
) : data && data.users.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
|
{/* Top Pagination */}
|
||||||
|
{data.totalPages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
total={data.total}
|
||||||
|
perPage={data.perPage}
|
||||||
|
onPageChange={(newPage) => updateParams({ page: String(newPage) })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bulk selection controls */}
|
{/* Bulk selection controls */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-3 flex flex-wrap items-center justify-between gap-2">
|
<CardContent className="py-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
@@ -334,12 +360,12 @@ export function MembersContent() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>Member</TableHead>
|
<SortableHeader label="Member" column="name" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||||
<TableHead>Role</TableHead>
|
<SortableHeader label="Role" column="role" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||||
<TableHead>Expertise</TableHead>
|
<TableHead>Expertise</TableHead>
|
||||||
<TableHead>Assignments</TableHead>
|
<TableHead>Assignments</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<SortableHeader label="Status" column="status" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||||
<TableHead>Last Login</TableHead>
|
<SortableHeader label="Last Login" column="lastLoginAt" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -681,6 +707,17 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Top Pagination */}
|
||||||
|
{data.totalPages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
total={data.total}
|
||||||
|
perPage={data.perPage}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Desktop table */}
|
{/* Desktop table */}
|
||||||
<Card className="hidden md:block">
|
<Card className="hidden md:block">
|
||||||
<Table>
|
<Table>
|
||||||
|
|||||||
@@ -274,6 +274,36 @@ export function ObserverProjectsContent() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : projectsData && projectsData.projects.length > 0 ? (
|
) : projectsData && projectsData.projects.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
|
{/* Top Pagination */}
|
||||||
|
{projectsData.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Page {projectsData.page} of {projectsData.totalPages} ·{' '}
|
||||||
|
{projectsData.total} result{projectsData.total !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setPage((p) => Math.min(projectsData.totalPages, p + 1))
|
||||||
|
}
|
||||||
|
disabled={page >= projectsData.totalPages}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
|
|||||||
45
src/components/shared/sortable-header.tsx
Normal file
45
src/components/shared/sortable-header.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { TableHead } from '@/components/ui/table'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
|
|
||||||
|
type SortableHeaderProps = {
|
||||||
|
label: string
|
||||||
|
column: string
|
||||||
|
currentSort?: string
|
||||||
|
currentDir?: 'asc' | 'desc'
|
||||||
|
onSort: (column: string) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortableHeader({
|
||||||
|
label,
|
||||||
|
column,
|
||||||
|
currentSort,
|
||||||
|
currentDir,
|
||||||
|
onSort,
|
||||||
|
className,
|
||||||
|
}: SortableHeaderProps) {
|
||||||
|
const isActive = currentSort === column
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableHead
|
||||||
|
className={cn('cursor-pointer select-none hover:bg-muted/50 transition-colors', className)}
|
||||||
|
onClick={() => onSort(column)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{label}
|
||||||
|
{isActive ? (
|
||||||
|
currentDir === 'asc' ? (
|
||||||
|
<ArrowUp className="h-3.5 w-3.5 text-foreground" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="h-3.5 w-3.5 text-foreground" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -76,6 +76,8 @@ export const projectRouter = router({
|
|||||||
hasAssignments: z.boolean().optional(),
|
hasAssignments: z.boolean().optional(),
|
||||||
page: z.number().int().min(1).default(1),
|
page: z.number().int().min(1).default(1),
|
||||||
perPage: z.number().int().min(1).max(200).default(20),
|
perPage: z.number().int().min(1).max(200).default(20),
|
||||||
|
sortBy: z.enum(['title', 'category', 'program', 'assignments', 'status', 'createdAt']).optional(),
|
||||||
|
sortDir: z.enum(['asc', 'desc']).optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
@@ -83,10 +85,23 @@ export const projectRouter = router({
|
|||||||
programId, roundId, excludeInRoundId, status, statuses, unassignedOnly, search, tags,
|
programId, roundId, excludeInRoundId, status, statuses, unassignedOnly, search, tags,
|
||||||
competitionCategory, oceanIssue, country,
|
competitionCategory, oceanIssue, country,
|
||||||
wantsMentorship, hasFiles, hasAssignments,
|
wantsMentorship, hasFiles, hasAssignments,
|
||||||
page, perPage,
|
page, perPage, sortBy, sortDir,
|
||||||
} = input
|
} = input
|
||||||
const skip = (page - 1) * perPage
|
const skip = (page - 1) * perPage
|
||||||
|
|
||||||
|
const dir = sortDir ?? 'desc'
|
||||||
|
const orderBy: Prisma.ProjectOrderByWithRelationInput = (() => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'title': return { title: dir }
|
||||||
|
case 'category': return { competitionCategory: dir }
|
||||||
|
case 'program': return { program: { name: dir } }
|
||||||
|
case 'assignments': return { assignments: { _count: dir } }
|
||||||
|
case 'status': return { status: dir }
|
||||||
|
case 'createdAt':
|
||||||
|
default: return { createdAt: dir }
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
// Build where clause
|
// Build where clause
|
||||||
const where: Record<string, unknown> = {}
|
const where: Record<string, unknown> = {}
|
||||||
|
|
||||||
@@ -151,7 +166,7 @@ export const projectRouter = router({
|
|||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: perPage,
|
take: perPage,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy,
|
||||||
include: {
|
include: {
|
||||||
program: { select: { id: true, name: true, year: true } },
|
program: { select: { id: true, name: true, year: true } },
|
||||||
_count: { select: { assignments: true, files: true } },
|
_count: { select: { assignments: true, files: true } },
|
||||||
|
|||||||
@@ -238,10 +238,12 @@ export const userRouter = router({
|
|||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
page: z.number().int().min(1).default(1),
|
page: z.number().int().min(1).default(1),
|
||||||
perPage: z.number().int().min(1).max(100).default(20),
|
perPage: z.number().int().min(1).max(100).default(20),
|
||||||
|
sortBy: z.enum(['name', 'email', 'role', 'status', 'lastLoginAt', 'createdAt']).optional(),
|
||||||
|
sortDir: z.enum(['asc', 'desc']).optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const { role, roles, status, search, page, perPage } = input
|
const { role, roles, status, search, page, perPage, sortBy, sortDir } = input
|
||||||
const skip = (page - 1) * perPage
|
const skip = (page - 1) * perPage
|
||||||
|
|
||||||
const where: Record<string, unknown> = {}
|
const where: Record<string, unknown> = {}
|
||||||
@@ -259,12 +261,17 @@ export const userRouter = router({
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dir = sortDir ?? 'asc'
|
||||||
|
const orderBy: Record<string, string> = sortBy
|
||||||
|
? { [sortBy]: dir }
|
||||||
|
: { createdAt: 'desc' }
|
||||||
|
|
||||||
const [users, total] = await Promise.all([
|
const [users, total] = await Promise.all([
|
||||||
ctx.prisma.user.findMany({
|
ctx.prisma.user.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: perPage,
|
take: perPage,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user