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
key={status}
variant={statusColors[status] || 'secondary'}
className="text-xs font-normal"
>
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
</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={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: '',