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

- 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:
2026-03-05 14:49:17 +01:00
parent 2be7318cb9
commit fd2624f198
6 changed files with 173 additions and 13 deletions

View File

@@ -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<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 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() {
<MembersSkeleton />
) : 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 */}
<Card>
<CardContent className="py-3 flex flex-wrap items-center justify-between gap-2">
@@ -334,12 +360,12 @@ export function MembersContent() {
/>
)}
</TableHead>
<TableHead>Member</TableHead>
<TableHead>Role</TableHead>
<SortableHeader label="Member" column="name" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<SortableHeader label="Role" column="role" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead>Expertise</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Login</TableHead>
<SortableHeader label="Status" column="status" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<SortableHeader label="Last Login" column="lastLoginAt" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -681,6 +707,17 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search:
return (
<>
{/* Top Pagination */}
{data.totalPages > 1 && (
<Pagination
page={page}
totalPages={data.totalPages}
total={data.total}
perPage={data.perPage}
onPageChange={setPage}
/>
)}
{/* Desktop table */}
<Card className="hidden md:block">
<Table>

View File

@@ -274,6 +274,36 @@ export function ObserverProjectsContent() {
</Card>
) : 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} &middot;{' '}
{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">
<Card>
<CardContent className="p-0">

View 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>
)
}