From fd2624f1980d313bf4d033bf68fcd2faf7360afd Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 5 Mar 2026 14:49:17 +0100 Subject: [PATCH] feat: fix project status counts, add top pagination and sortable columns - 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 --- src/app/(admin)/admin/projects/page.tsx | 36 ++++++++++++--- src/components/admin/members-content.tsx | 45 +++++++++++++++++-- .../observer/observer-projects-content.tsx | 30 +++++++++++++ src/components/shared/sortable-header.tsx | 45 +++++++++++++++++++ src/server/routers/project.ts | 19 +++++++- src/server/routers/user.ts | 11 ++++- 6 files changed, 173 insertions(+), 13 deletions(-) create mode 100644 src/components/shared/sortable-header.tsx diff --git a/src/app/(admin)/admin/projects/page.tsx b/src/app/(admin)/admin/projects/page.tsx index f7713a6..aa77565 100644 --- a/src/app/(admin)/admin/projects/page.tsx +++ b/src/app/(admin)/admin/projects/page.tsx @@ -94,6 +94,7 @@ import { ProjectLogo } from '@/components/shared/project-logo' import { BulkNotificationDialog } from '@/components/admin/projects/bulk-notification-dialog' import { Pagination } from '@/components/shared/pagination' +import { SortableHeader } from '@/components/shared/sortable-header' import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries' import { CountryFlagImg } from '@/components/ui/country-select' import { @@ -215,6 +216,8 @@ export default function ProjectsPage() { const [perPage, setPerPage] = useState(parsed.perPage || 20) const [searchInput, setSearchInput] = useState(parsed.search) const [viewMode, setViewMode] = useState<'table' | 'card'>('table') + const [sortBy, setSortBy] = useState(undefined) + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc') // Fetch display settings const { data: displaySettings } = trpc.settings.getMultiple.useQuery({ @@ -260,6 +263,16 @@ export default function ProjectsPage() { 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 const queryInput = { search: filters.search || undefined, @@ -298,6 +311,8 @@ export default function ProjectsPage() { hasAssignments: filters.hasAssignments, page, perPage, + sortBy: sortBy as 'title' | 'category' | 'program' | 'assignments' | 'status' | 'createdAt' | undefined, + sortDir: sortBy ? sortDir : undefined, } const utils = trpc.useUtils() @@ -876,6 +891,17 @@ export default function ProjectsPage() { ) : data ? ( <> + {/* Top Pagination */} + {data.totalPages > 1 && ( + + )} + {/* Table View */} {viewMode === 'table' ? ( <> @@ -891,12 +917,12 @@ export default function ProjectsPage() { aria-label="Select all projects" /> - Project - Category - Program + + + Tags - Assignments - Status + + Actions diff --git a/src/components/admin/members-content.tsx b/src/components/admin/members-content.tsx index 4ea2de0..71f5e20 100644 --- a/src/components/admin/members-content.tsx +++ b/src/components/admin/members-content.tsx @@ -28,6 +28,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { UserAvatar } from '@/components/shared/user-avatar' import { UserActions, UserMobileActions } from '@/components/admin/user-actions' 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 { toast } from 'sonner' import { formatRelativeTime } from '@/lib/utils' @@ -138,6 +139,18 @@ export function MembersContent() { const roles = TAB_ROLES[tab] const [selectedIds, setSelectedIds] = useState>(new Set()) + const [sortBy, setSortBy] = useState(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 currentUserRole = currentUser?.role as RoleValue | undefined @@ -147,6 +160,8 @@ export function MembersContent() { search: search || undefined, page, perPage: 20, + sortBy: sortBy as 'name' | 'email' | 'role' | 'status' | 'lastLoginAt' | 'createdAt' | undefined, + sortDir: sortBy ? sortDir : undefined, }) const invitableIdsQuery = trpc.user.listInvitableIds.useQuery( @@ -290,6 +305,17 @@ export function MembersContent() { ) : data && data.users.length > 0 ? ( <> + {/* Top Pagination */} + {data.totalPages > 1 && ( + updateParams({ page: String(newPage) })} + /> + )} + {/* Bulk selection controls */} @@ -334,12 +360,12 @@ export function MembersContent() { /> )} - Member - Role + + Expertise Assignments - Status - Last Login + + Actions @@ -681,6 +707,17 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search: return ( <> + {/* Top Pagination */} + {data.totalPages > 1 && ( + + )} + {/* Desktop table */} diff --git a/src/components/observer/observer-projects-content.tsx b/src/components/observer/observer-projects-content.tsx index 9ae5be0..6fd2496 100644 --- a/src/components/observer/observer-projects-content.tsx +++ b/src/components/observer/observer-projects-content.tsx @@ -274,6 +274,36 @@ export function ObserverProjectsContent() { ) : projectsData && projectsData.projects.length > 0 ? ( <> + {/* Top Pagination */} + {projectsData.totalPages > 1 && ( +
+

+ Page {projectsData.page} of {projectsData.totalPages} ·{' '} + {projectsData.total} result{projectsData.total !== 1 ? 's' : ''} +

+
+ + +
+
+ )} +
diff --git a/src/components/shared/sortable-header.tsx b/src/components/shared/sortable-header.tsx new file mode 100644 index 0000000..7073c94 --- /dev/null +++ b/src/components/shared/sortable-header.tsx @@ -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 ( + onSort(column)} + > +
+ {label} + {isActive ? ( + currentDir === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+ ) +} diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index d6c8c40..c3edea3 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -76,6 +76,8 @@ export const projectRouter = router({ hasAssignments: z.boolean().optional(), page: z.number().int().min(1).default(1), 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 }) => { @@ -83,10 +85,23 @@ export const projectRouter = router({ programId, roundId, excludeInRoundId, status, statuses, unassignedOnly, search, tags, competitionCategory, oceanIssue, country, wantsMentorship, hasFiles, hasAssignments, - page, perPage, + page, perPage, sortBy, sortDir, } = input 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 const where: Record = {} @@ -151,7 +166,7 @@ export const projectRouter = router({ where, skip, take: perPage, - orderBy: { createdAt: 'desc' }, + orderBy, include: { program: { select: { id: true, name: true, year: true } }, _count: { select: { assignments: true, files: true } }, diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index c1db451..5812a79 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -238,10 +238,12 @@ export const userRouter = router({ search: z.string().optional(), page: z.number().int().min(1).default(1), 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 }) => { - const { role, roles, status, search, page, perPage } = input + const { role, roles, status, search, page, perPage, sortBy, sortDir } = input const skip = (page - 1) * perPage const where: Record = {} @@ -259,12 +261,17 @@ export const userRouter = router({ ] } + const dir = sortDir ?? 'asc' + const orderBy: Record = sortBy + ? { [sortBy]: dir } + : { createdAt: 'desc' } + const [users, total] = await Promise.all([ ctx.prisma.user.findMany({ where, skip, take: perPage, - orderBy: { createdAt: 'desc' }, + orderBy, select: { id: true, email: true,