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 { 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>

View File

@@ -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>

View File

@@ -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} &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"> <div className="hidden md:block">
<Card> <Card>
<CardContent className="p-0"> <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(), 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 } },
@@ -165,28 +180,40 @@ export const projectRouter = router({
}, },
}), }),
ctx.prisma.project.count({ where }), 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 () => { (async () => {
const stateFilter = where.programId ? { project: { programId: where.programId as string } } : {} const stateFilter: Record<string, unknown> = {}
const states = ['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'] as const if (where.programId) stateFilter.project = { programId: where.programId as string }
const counts = await Promise.all(
states.map(async (state) => ({ const allStates = await ctx.prisma.projectRoundState.findMany({
state, where: stateFilter,
count: await ctx.prisma.projectRoundState.findMany({ select: { projectId: true, state: true, round: { select: { sortOrder: true } } },
where: { ...stateFilter, state }, orderBy: { round: { sortOrder: 'desc' } },
select: { projectId: true }, })
distinct: ['projectId'],
}).then((rows) => rows.length), // Pick the latest round state per project
})) const latestByProject = new Map<string, string>()
) for (const s of allStates) {
return counts 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> = {} const statusCounts: Record<string, number> = {}
for (const g of roundStateCounts) { for (const [state, count] of roundStateCounts) {
statusCounts[g.state] = g.count statusCounts[state] = count
} }
const projectsWithLogos = await attachProjectLogoUrls(projects) const projectsWithLogos = await attachProjectLogoUrls(projects)

View File

@@ -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,