feat: clickable status badges, observer status alignment, CSV export all
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m36s

- Admin projects: status summary badges are clickable to filter by round state
  with ring highlight, opacity fade, and clear button
- Add roundStates filter param to project.list backend query
  (filters by latest round state per project, consistent with counts)
- Observer status dropdown now uses ProjectRoundState values
  (Pending/In Progress/Completed/Passed/Rejected/Withdrawn)
- Observer status derived from latest ProjectRoundState instead of stale Project.status
- Observer CSV export fetches all matching projects, not just current page
- Add PENDING and PASSED styles to StatusBadge component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 17:19:12 +01:00
parent ffe12a9e85
commit 0d94ee1fe8
7 changed files with 114 additions and 42 deletions

View File

@@ -5,6 +5,7 @@ import Link from 'next/link'
import { useSearchParams, usePathname } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import {
Card,
CardContent,
@@ -145,6 +146,9 @@ function parseFiltersFromParams(
statuses: searchParams.get('status')
? searchParams.get('status')!.split(',')
: [],
roundStates: searchParams.get('roundState')
? searchParams.get('roundState')!.split(',')
: [],
roundId: searchParams.get('round') || '',
competitionCategory: searchParams.get('category') || '',
oceanIssue: searchParams.get('issue') || '',
@@ -179,6 +183,8 @@ function filtersToParams(
if (filters.search) params.set('q', filters.search)
if (filters.statuses.length > 0)
params.set('status', filters.statuses.join(','))
if (filters.roundStates.length > 0)
params.set('roundState', filters.roundStates.join(','))
if (filters.roundId) params.set('round', filters.roundId)
if (filters.competitionCategory)
params.set('category', filters.competitionCategory)
@@ -204,6 +210,7 @@ export default function ProjectsPage() {
const [filters, setFilters] = useState<ProjectFilters>({
search: parsed.search,
statuses: parsed.statuses,
roundStates: parsed.roundStates,
roundId: parsed.roundId,
competitionCategory: parsed.competitionCategory,
oceanIssue: parsed.oceanIssue,
@@ -309,6 +316,12 @@ export default function ProjectsPage() {
wantsMentorship: filters.wantsMentorship,
hasFiles: filters.hasFiles,
hasAssignments: filters.hasAssignments,
roundStates:
filters.roundStates.length > 0
? (filters.roundStates as Array<
'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'PASSED' | 'REJECTED' | 'WITHDRAWN'
>)
: undefined,
page,
perPage,
sortBy: sortBy as 'title' | 'category' | 'program' | 'assignments' | 'status' | 'createdAt' | undefined,
@@ -752,7 +765,7 @@ export default function ProjectsPage() {
/>
{/* Stats Summary + View Toggle */}
{data && data.projects.length > 0 && (
{data && (Object.keys(data.statusCounts ?? {}).length > 0 || data.projects.length > 0) && (
<div className="flex items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-2 text-sm">
{Object.entries(data.statusCounts ?? {})
@@ -760,15 +773,43 @@ export default function ProjectsPage() {
const order = ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
return order.indexOf(a) - order.indexOf(b)
})
.map(([status, count]) => (
<Badge
.map(([status, count]) => {
const isActive = filters.roundStates.includes(status)
return (
<button
key={status}
type="button"
onClick={() => {
const next = isActive
? filters.roundStates.filter((s) => s !== status)
: [...filters.roundStates, status]
handleFiltersChange({ ...filters, roundStates: next })
}}
className="inline-flex items-center"
>
<Badge
variant={statusColors[status] || 'secondary'}
className="text-xs font-normal"
className={cn(
'text-xs font-normal cursor-pointer transition-all',
isActive && 'ring-2 ring-offset-1 ring-primary',
!isActive && filters.roundStates.length > 0 && 'opacity-50'
)}
>
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
</Badge>
))}
</button>
)
})}
{filters.roundStates.length > 0 && (
<button
type="button"
onClick={() => handleFiltersChange({ ...filters, roundStates: [] })}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors ml-1"
>
<X className="h-3 w-3" />
Clear
</button>
)}
{data.total > data.projects.length && (
<span className="text-xs text-muted-foreground ml-1">
(page {data.page} of {data.totalPages})

View File

@@ -63,6 +63,7 @@ const ISSUE_LABELS: Record<string, string> = {
export interface ProjectFilters {
search: string
statuses: string[]
roundStates: string[]
roundId: string
competitionCategory: string
oceanIssue: string
@@ -94,6 +95,7 @@ export function ProjectFiltersBar({
const activeFilterCount = [
filters.statuses.length > 0,
filters.roundStates.length > 0,
filters.roundId !== '',
filters.competitionCategory !== '',
filters.oceanIssue !== '',
@@ -114,6 +116,7 @@ export function ProjectFiltersBar({
onChange({
search: filters.search,
statuses: [],
roundStates: [],
roundId: '',
competitionCategory: '',
oceanIssue: '',

View File

@@ -231,7 +231,7 @@ export function EvaluationPanel({ roundId, programId }: { roundId: string; progr
{projects
.filter((p) => {
const s = p.observerStatus ?? p.status
return s !== 'NOT_REVIEWED' && s !== 'SUBMITTED'
return s !== 'PENDING'
})
.slice(0, 6)
.map((p) => (

View File

@@ -141,11 +141,19 @@ export function ObserverProjectsContent() {
{ refetchInterval: 30_000 },
)
const utils = trpc.useUtils()
const handleRequestCsvData = useCallback(async () => {
setCsvLoading(true)
try {
const allData = await new Promise<typeof projectsData>((resolve) => {
resolve(projectsData)
const allData = await utils.analytics.getAllProjects.fetch({
roundId: roundFilter !== 'all' ? roundFilter : undefined,
search: debouncedSearch || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
sortBy,
sortDir,
page: 1,
perPage: 100,
exportAll: true,
})
if (!allData?.projects) {
@@ -158,7 +166,7 @@ export function ObserverProjectsContent() {
teamName: p.teamName ?? '',
country: p.country ?? '',
roundName: p.roundName ?? '',
status: p.status,
status: p.observerStatus ?? p.status,
averageScore: p.averageScore !== null ? p.averageScore.toFixed(2) : '',
evaluationCount: p.evaluationCount,
}))
@@ -174,7 +182,7 @@ export function ObserverProjectsContent() {
setCsvLoading(false)
return undefined
}
}, [projectsData])
}, [utils, roundFilter, debouncedSearch, statusFilter, sortBy, sortDir])
const SortIcon = ({ column }: { column: 'title' | 'score' | 'evaluations' }) => {
if (sortBy !== column)
@@ -251,13 +259,12 @@ export function ObserverProjectsContent() {
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="SUBMITTED">Submitted</SelectItem>
<SelectItem value="NOT_REVIEWED">Not Reviewed</SelectItem>
<SelectItem value="UNDER_REVIEW">Under Review</SelectItem>
<SelectItem value="REVIEWED">Reviewed</SelectItem>
<SelectItem value="SEMIFINALIST">Semi-finalist</SelectItem>
<SelectItem value="FINALIST">Finalist</SelectItem>
<SelectItem value="PENDING">Pending</SelectItem>
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
<SelectItem value="COMPLETED">Completed</SelectItem>
<SelectItem value="PASSED">Passed</SelectItem>
<SelectItem value="REJECTED">Rejected</SelectItem>
<SelectItem value="WITHDRAWN">Withdrawn</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -28,9 +28,11 @@ const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?
NOT_REVIEWED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200 dark:text-slate-400' },
REVIEWED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
// Evaluation statuses
// Round state statuses
PENDING: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200 dark:text-slate-400' },
IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
PASSED: { variant: 'default', className: 'bg-green-500/10 text-green-700 border-green-200 dark:text-green-400' },
// User statuses
NONE: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-500 border-slate-200 dark:text-slate-400' },

View File

@@ -988,17 +988,19 @@ export const analyticsRouter = router({
sortDir: z.enum(['asc', 'desc']).default('asc'),
page: z.number().min(1).default(1),
perPage: z.number().min(1).max(100).default(20),
exportAll: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const effectivePerPage = input.exportAll ? 10000 : input.perPage
const where: Record<string, unknown> = {}
if (input.roundId) {
where.projectRoundStates = { some: { roundId: input.roundId } }
}
const OBSERVER_DERIVED_STATUSES = ['NOT_REVIEWED', 'UNDER_REVIEW', 'REVIEWED']
if (input.status && !OBSERVER_DERIVED_STATUSES.includes(input.status)) {
const ROUND_STATE_STATUSES = ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
if (input.status && !ROUND_STATE_STATUSES.includes(input.status)) {
where.status = input.status
}
@@ -1047,9 +1049,9 @@ export const analyticsRouter = router({
},
},
orderBy: prismaOrderBy,
// When sorting by computed fields or filtering by observer-derived status, fetch all then slice in JS
...(input.sortBy === 'title' && !OBSERVER_DERIVED_STATUSES.includes(input.status ?? '')
? { skip: (input.page - 1) * input.perPage, take: input.perPage }
// When sorting by computed fields or filtering by round-state status, fetch all then slice in JS
...(input.sortBy === 'title' && !ROUND_STATE_STATUSES.includes(input.status ?? '') && !input.exportAll
? { skip: (input.page - 1) * effectivePerPage, take: effectivePerPage }
: {}),
}),
ctx.prisma.project.count({ where }),
@@ -1077,15 +1079,8 @@ export const analyticsRouter = router({
? p.assignments.find((a) => a.roundId === input.roundId)
: p.assignments[0]
// Derive observer-friendly status
let observerStatus: string
if (p.status === 'REJECTED') observerStatus = 'REJECTED'
else if (p.status === 'SEMIFINALIST') observerStatus = 'SEMIFINALIST'
else if (p.status === 'FINALIST') observerStatus = 'FINALIST'
else if (p.status === 'SUBMITTED') observerStatus = 'SUBMITTED'
else if (submitted.length > 0) observerStatus = 'REVIEWED'
else if (drafts.length > 0) observerStatus = 'UNDER_REVIEW'
else observerStatus = 'NOT_REVIEWED'
// Derive observer-friendly status from latest round state
const observerStatus = furthestRoundState?.state ?? 'PENDING'
const logoUrl = await getProjectLogoUrl(p.logoKey, p.logoProvider)
@@ -1104,8 +1099,8 @@ export const analyticsRouter = router({
}
}))
// Filter by observer-derived status in JS
const observerStatusFilter = input.status && OBSERVER_DERIVED_STATUSES.includes(input.status)
// Filter by round-state status in JS
const observerStatusFilter = input.status && ROUND_STATE_STATUSES.includes(input.status)
? input.status
: null
const filtered = observerStatusFilter
@@ -1132,15 +1127,15 @@ export const analyticsRouter = router({
// Paginate in JS for computed-field sorts or observer status filter
const needsJsPagination = input.sortBy !== 'title' || observerStatusFilter
const paginated = needsJsPagination
? sorted.slice((input.page - 1) * input.perPage, input.page * input.perPage)
? sorted.slice((input.page - 1) * effectivePerPage, input.page * effectivePerPage)
: sorted
return {
projects: paginated,
total: filteredTotal,
page: input.page,
perPage: input.perPage,
totalPages: Math.ceil(filteredTotal / input.perPage),
perPage: effectivePerPage,
totalPages: Math.ceil(filteredTotal / effectivePerPage),
}
}),

View File

@@ -74,6 +74,9 @@ export const projectRouter = router({
wantsMentorship: z.boolean().optional(),
hasFiles: z.boolean().optional(),
hasAssignments: z.boolean().optional(),
roundStates: z.array(z.enum([
'PENDING', 'IN_PROGRESS', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN',
])).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(),
@@ -84,7 +87,7 @@ export const projectRouter = router({
const {
programId, roundId, excludeInRoundId, status, statuses, unassignedOnly, search, tags,
competitionCategory, oceanIssue, country,
wantsMentorship, hasFiles, hasAssignments,
wantsMentorship, hasFiles, hasAssignments, roundStates,
page, perPage, sortBy, sortDir,
} = input
const skip = (page - 1) * perPage
@@ -143,6 +146,27 @@ export const projectRouter = router({
if (hasAssignments === true) where.assignments = { some: {} }
if (hasAssignments === false) where.assignments = { none: {} }
// Filter by latest round state (matches the statusCounts logic)
if (roundStates?.length) {
const stateFilter: Record<string, unknown> = {}
if (programId) stateFilter.project = { programId }
const allStates = await ctx.prisma.projectRoundState.findMany({
where: stateFilter,
select: { projectId: true, state: true, round: { select: { sortOrder: true } } },
orderBy: { round: { sortOrder: 'desc' } },
})
const latestByProject = new Map<string, string>()
for (const s of allStates) {
if (!latestByProject.has(s.projectId)) {
latestByProject.set(s.projectId, s.state)
}
}
const matchingIds = [...latestByProject.entries()]
.filter(([, state]) => roundStates.includes(state as typeof roundStates[number]))
.map(([id]) => id)
where.id = { in: matchingIds }
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },