feat: clickable status badges, observer status alignment, CSV export all
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m36s
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:
@@ -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})
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@@ -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' } },
|
||||
|
||||
Reference in New Issue
Block a user