Compare commits

...

2 Commits

Author SHA1 Message Date
fd2624f198 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>
2026-03-05 14:49:17 +01:00
2be7318cb9 fix: project status counts now show latest round state per project
Previously counted distinct projects per state across ALL rounds,
inflating counts (e.g., 215 Passed when many were later rejected).
Now picks each project's latest round state (highest sortOrder) to
determine its current status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:44:14 +01:00
6 changed files with 202 additions and 30 deletions

View File

@@ -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<string | undefined>(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() {
</Card>
) : data ? (
<>
{/* Top Pagination */}
{data.totalPages > 1 && (
<Pagination
page={data.page}
totalPages={data.totalPages}
total={data.total}
perPage={perPage}
onPageChange={setPage}
/>
)}
{/* Table View */}
{viewMode === 'table' ? (
<>
@@ -891,12 +917,12 @@ export default function ProjectsPage() {
aria-label="Select all projects"
/>
</TableHead>
<TableHead className="min-w-[280px]">Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Program</TableHead>
<SortableHeader label="Project" column="title" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} className="min-w-[280px]" />
<SortableHeader label="Category" column="category" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<SortableHeader label="Program" column="program" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead>Tags</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Status</TableHead>
<SortableHeader label="Assignments" column="assignments" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<SortableHeader label="Status" column="status" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>

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

View File

@@ -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<string, unknown> = {}
@@ -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 } },
@@ -165,28 +180,40 @@ export const projectRouter = router({
},
}),
ctx.prisma.project.count({ where }),
// Count distinct projects per state (avoids double-counting projects that passed multiple rounds)
// Count projects by their LATEST round state (highest sortOrder round).
// This avoids inflated counts where a project that passed round 1
// but was rejected in round 2 shows up in both PASSED and REJECTED.
(async () => {
const stateFilter = where.programId ? { project: { programId: where.programId as string } } : {}
const states = ['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'] as const
const counts = await Promise.all(
states.map(async (state) => ({
state,
count: await ctx.prisma.projectRoundState.findMany({
where: { ...stateFilter, state },
select: { projectId: true },
distinct: ['projectId'],
}).then((rows) => rows.length),
}))
)
return counts
const stateFilter: Record<string, unknown> = {}
if (where.programId) stateFilter.project = { programId: where.programId as string }
const allStates = await ctx.prisma.projectRoundState.findMany({
where: stateFilter,
select: { projectId: true, state: true, round: { select: { sortOrder: true } } },
orderBy: { round: { sortOrder: 'desc' } },
})
// Pick the latest round state per project
const latestByProject = new Map<string, string>()
for (const s of allStates) {
if (!latestByProject.has(s.projectId)) {
latestByProject.set(s.projectId, s.state)
}
}
// Aggregate counts
const countMap = new Map<string, number>()
for (const state of latestByProject.values()) {
countMap.set(state, (countMap.get(state) ?? 0) + 1)
}
return countMap
})(),
])
// Build round-state counts (distinct projects per state)
// Build round-state counts from the latest-state map
const statusCounts: Record<string, number> = {}
for (const g of roundStateCounts) {
statusCounts[g.state] = g.count
for (const [state, count] of roundStateCounts) {
statusCounts[state] = count
}
const projectsWithLogos = await attachProjectLogoUrls(projects)

View File

@@ -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<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([
ctx.prisma.user.findMany({
where,
skip,
take: perPage,
orderBy: { createdAt: 'desc' },
orderBy,
select: {
id: true,
email: true,