diff --git a/src/app/(admin)/admin/projects/page.tsx b/src/app/(admin)/admin/projects/page.tsx index aa77565..a2fd83d 100644 --- a/src/app/(admin)/admin/projects/page.tsx +++ b/src/app/(admin)/admin/projects/page.tsx @@ -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({ 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) && (
{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]) => ( - - {count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')} - - ))} + .map(([status, count]) => { + const isActive = filters.roundStates.includes(status) + return ( + + ) + })} + {filters.roundStates.length > 0 && ( + + )} {data.total > data.projects.length && ( (page {data.page} of {data.totalPages}) diff --git a/src/app/(admin)/admin/projects/project-filters.tsx b/src/app/(admin)/admin/projects/project-filters.tsx index b4fd3f0..6492a4e 100644 --- a/src/app/(admin)/admin/projects/project-filters.tsx +++ b/src/app/(admin)/admin/projects/project-filters.tsx @@ -63,6 +63,7 @@ const ISSUE_LABELS: Record = { 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: '', diff --git a/src/components/observer/dashboard/evaluation-panel.tsx b/src/components/observer/dashboard/evaluation-panel.tsx index 906dfea..231cb16 100644 --- a/src/components/observer/dashboard/evaluation-panel.tsx +++ b/src/components/observer/dashboard/evaluation-panel.tsx @@ -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) => ( diff --git a/src/components/observer/observer-projects-content.tsx b/src/components/observer/observer-projects-content.tsx index 6fd2496..b80a69b 100644 --- a/src/components/observer/observer-projects-content.tsx +++ b/src/components/observer/observer-projects-content.tsx @@ -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((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() { All Statuses - Submitted - Not Reviewed - Under Review - Reviewed - Semi-finalist - Finalist + Pending + In Progress + Completed + Passed Rejected + Withdrawn
diff --git a/src/components/shared/status-badge.tsx b/src/components/shared/status-badge.tsx index 6ae60de..1c0b060 100644 --- a/src/components/shared/status-badge.tsx +++ b/src/components/shared/status-badge.tsx @@ -28,9 +28,11 @@ const STATUS_STYLES: Record { + const effectivePerPage = input.exportAll ? 10000 : input.perPage const where: Record = {} 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), } }), diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index fd7d0b9..1059f7e 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -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 = {} + 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() + 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' } },