All checks were successful
Build and Push Docker Image / build (push) Successful in 9m30s
Add attachProjectLogoUrls utility mirroring avatar URL pattern. Pipe project.list and analytics.getAllProjects through logo URL resolver so ProjectLogo components receive presigned URLs. Add logos to observer projects table and mobile cards. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
510 lines
19 KiB
TypeScript
510 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useCallback } from 'react'
|
|
import Link from 'next/link'
|
|
import type { Route } from 'next'
|
|
import { useRouter } from 'next/navigation'
|
|
import { trpc } from '@/lib/trpc/client'
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardDescription,
|
|
} from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { StatusBadge } from '@/components/shared/status-badge'
|
|
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
|
import { scoreGradient } from '@/components/charts/chart-theme'
|
|
import {
|
|
Search,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ArrowUpDown,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
ClipboardList,
|
|
Download,
|
|
X,
|
|
} from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { useDebouncedCallback } from 'use-debounce'
|
|
import { ProjectLogo } from '@/components/shared/project-logo'
|
|
|
|
|
|
export function ObserverProjectsContent() {
|
|
const router = useRouter()
|
|
const [search, setSearch] = useState('')
|
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
const [roundFilter, setRoundFilter] = useState('all')
|
|
const [statusFilter, setStatusFilter] = useState('all')
|
|
const [sortBy, setSortBy] = useState<'title' | 'score' | 'evaluations'>('title')
|
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
|
const [page, setPage] = useState(1)
|
|
const [perPage] = useState(20)
|
|
const [csvOpen, setCsvOpen] = useState(false)
|
|
const [csvExportData, setCsvExportData] = useState<
|
|
{ data: Record<string, unknown>[]; columns: string[] } | undefined
|
|
>(undefined)
|
|
const [csvLoading, setCsvLoading] = useState(false)
|
|
|
|
const debouncedSetSearch = useDebouncedCallback((value: string) => {
|
|
setDebouncedSearch(value)
|
|
setPage(1)
|
|
}, 300)
|
|
|
|
const handleSearchChange = (value: string) => {
|
|
setSearch(value)
|
|
debouncedSetSearch(value)
|
|
}
|
|
|
|
const handleRoundChange = (value: string) => {
|
|
setRoundFilter(value)
|
|
setPage(1)
|
|
}
|
|
|
|
const handleStatusChange = (value: string) => {
|
|
setStatusFilter(value)
|
|
setPage(1)
|
|
}
|
|
|
|
const handleSort = (column: 'title' | 'score' | 'evaluations') => {
|
|
if (sortBy === column) {
|
|
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
|
} else {
|
|
setSortBy(column)
|
|
setSortDir(column === 'title' ? 'asc' : 'desc')
|
|
}
|
|
setPage(1)
|
|
}
|
|
|
|
const clearFilters = () => {
|
|
setSearch('')
|
|
setDebouncedSearch('')
|
|
setRoundFilter('all')
|
|
setStatusFilter('all')
|
|
setPage(1)
|
|
}
|
|
|
|
const activeFilterCount =
|
|
(debouncedSearch ? 1 : 0) +
|
|
(roundFilter !== 'all' ? 1 : 0) +
|
|
(statusFilter !== 'all' ? 1 : 0)
|
|
|
|
const { data: programs } = trpc.program.list.useQuery(
|
|
{ includeStages: true },
|
|
{ refetchInterval: 30_000 },
|
|
)
|
|
|
|
const rounds =
|
|
programs?.flatMap((p) =>
|
|
(p.rounds ?? []).map((r: { id: string; name: string; status: string; roundType?: string }) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
programName: `${p.year} Edition`,
|
|
status: r.status,
|
|
roundType: r.roundType,
|
|
})),
|
|
) ?? []
|
|
|
|
const roundIdParam = roundFilter !== 'all' ? roundFilter : undefined
|
|
|
|
const { data: projectsData, isLoading: projectsLoading } =
|
|
trpc.analytics.getAllProjects.useQuery(
|
|
{
|
|
roundId: roundIdParam,
|
|
search: debouncedSearch || undefined,
|
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
|
sortBy,
|
|
sortDir,
|
|
page,
|
|
perPage,
|
|
},
|
|
{ refetchInterval: 30_000 },
|
|
)
|
|
|
|
const handleRequestCsvData = useCallback(async () => {
|
|
setCsvLoading(true)
|
|
try {
|
|
const allData = await new Promise<typeof projectsData>((resolve) => {
|
|
resolve(projectsData)
|
|
})
|
|
|
|
if (!allData?.projects) {
|
|
setCsvLoading(false)
|
|
return undefined
|
|
}
|
|
|
|
const rows = allData.projects.map((p) => ({
|
|
title: p.title,
|
|
teamName: p.teamName ?? '',
|
|
country: p.country ?? '',
|
|
roundName: p.roundName ?? '',
|
|
status: p.status,
|
|
averageScore: p.averageScore !== null ? p.averageScore.toFixed(2) : '',
|
|
evaluationCount: p.evaluationCount,
|
|
}))
|
|
|
|
const result = {
|
|
data: rows,
|
|
columns: ['title', 'teamName', 'country', 'roundName', 'status', 'averageScore', 'evaluationCount'],
|
|
}
|
|
setCsvExportData(result)
|
|
setCsvLoading(false)
|
|
return result
|
|
} catch {
|
|
setCsvLoading(false)
|
|
return undefined
|
|
}
|
|
}, [projectsData])
|
|
|
|
const SortIcon = ({ column }: { column: 'title' | 'score' | 'evaluations' }) => {
|
|
if (sortBy !== column)
|
|
return <ArrowUpDown className="ml-1 inline h-3 w-3 text-muted-foreground/50" />
|
|
return sortDir === 'asc' ? (
|
|
<ArrowUp className="ml-1 inline h-3 w-3" />
|
|
) : (
|
|
<ArrowDown className="ml-1 inline h-3 w-3" />
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">All Projects</h1>
|
|
<p className="text-muted-foreground">
|
|
{projectsData
|
|
? `${projectsData.total} project${projectsData.total !== 1 ? 's' : ''} total`
|
|
: 'Loading projects...'}
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={() => setCsvOpen(true)}>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
Export CSV
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">Filters</CardTitle>
|
|
{activeFilterCount > 0 && (
|
|
<CardDescription className="flex items-center gap-2">
|
|
<Badge variant="secondary">{activeFilterCount} active</Badge>
|
|
<button
|
|
type="button"
|
|
onClick={clearFilters}
|
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
Clear all
|
|
</button>
|
|
</CardDescription>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search by title or team..."
|
|
value={search}
|
|
onChange={(e) => handleSearchChange(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
<Select value={roundFilter} onValueChange={handleRoundChange}>
|
|
<SelectTrigger className="w-full sm:w-[220px]">
|
|
<SelectValue placeholder="All Rounds" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Rounds</SelectItem>
|
|
{rounds.map((round) => (
|
|
<SelectItem key={round.id} value={round.id}>
|
|
{round.name}
|
|
{round.roundType ? ` (${round.roundType.replace(/_/g, ' ')})` : ''}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={statusFilter} onValueChange={handleStatusChange}>
|
|
<SelectTrigger className="w-full sm:w-[180px]">
|
|
<SelectValue placeholder="All Statuses" />
|
|
</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="REJECTED">Rejected</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{projectsLoading ? (
|
|
<Card>
|
|
<CardContent className="pt-6 space-y-2">
|
|
{[...Array(8)].map((_, i) => (
|
|
<Skeleton key={i} className="h-12 w-full" />
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
) : projectsData && projectsData.projects.length > 0 ? (
|
|
<>
|
|
<div className="hidden md:block">
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="pl-6">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSort('title')}
|
|
className="inline-flex items-center hover:text-foreground transition-colors"
|
|
>
|
|
Project
|
|
<SortIcon column="title" />
|
|
</button>
|
|
</TableHead>
|
|
<TableHead>Country</TableHead>
|
|
<TableHead>Round</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSort('score')}
|
|
className="inline-flex items-center hover:text-foreground transition-colors"
|
|
>
|
|
Score
|
|
<SortIcon column="score" />
|
|
</button>
|
|
</TableHead>
|
|
<TableHead>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSort('evaluations')}
|
|
className="inline-flex items-center hover:text-foreground transition-colors"
|
|
>
|
|
Jurors
|
|
<SortIcon column="evaluations" />
|
|
</button>
|
|
</TableHead>
|
|
<TableHead className="pr-6 w-10" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{projectsData.projects.map((project) => (
|
|
<TableRow
|
|
key={project.id}
|
|
className="cursor-pointer hover:bg-muted/50"
|
|
onClick={() => router.push(`/observer/projects/${project.id}`)}
|
|
>
|
|
<TableCell className="pl-6 max-w-[300px]">
|
|
<div className="flex items-center gap-3">
|
|
<ProjectLogo
|
|
project={project}
|
|
logoUrl={project.logoUrl}
|
|
size="sm"
|
|
fallback="initials"
|
|
/>
|
|
<div className="min-w-0">
|
|
<Link
|
|
href={`/observer/projects/${project.id}` as Route}
|
|
className="font-medium hover:underline truncate block"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{project.title}
|
|
</Link>
|
|
{project.teamName && (
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{project.teamName}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
{project.country ?? '-'}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className="text-xs whitespace-nowrap">
|
|
{project.roundName}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<StatusBadge status={project.observerStatus ?? project.status} />
|
|
</TableCell>
|
|
<TableCell>
|
|
{project.evaluationCount > 0 && project.averageScore !== null ? (
|
|
<div className="flex items-center gap-2">
|
|
<span className="tabular-nums w-8 text-sm">
|
|
{project.averageScore.toFixed(1)}
|
|
</span>
|
|
<div className="h-2 w-16 rounded-full bg-muted overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full"
|
|
style={{
|
|
width: `${(project.averageScore / 10) * 100}%`,
|
|
backgroundColor: scoreGradient(project.averageScore),
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<span className="text-muted-foreground text-sm">-</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="tabular-nums text-sm">
|
|
{project.evaluationCount}
|
|
</TableCell>
|
|
<TableCell className="pr-6">
|
|
<Link
|
|
href={`/observer/projects/${project.id}` as Route}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
</Link>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="space-y-3 md:hidden">
|
|
{projectsData.projects.map((project) => (
|
|
<Link
|
|
key={project.id}
|
|
href={`/observer/projects/${project.id}` as Route}
|
|
>
|
|
<Card className="transition-colors hover:bg-muted/50">
|
|
<CardContent className="pt-4 space-y-2">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<ProjectLogo
|
|
project={project}
|
|
logoUrl={project.logoUrl}
|
|
size="sm"
|
|
fallback="initials"
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="font-medium text-sm leading-tight truncate">
|
|
{project.title}
|
|
</p>
|
|
{project.teamName && (
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{project.teamName}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<StatusBadge status={project.observerStatus ?? project.status} />
|
|
</div>
|
|
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
|
<Badge variant="outline" className="text-xs">
|
|
{project.roundName}
|
|
</Badge>
|
|
{project.evaluationCount > 0 && (
|
|
<div className="flex gap-3">
|
|
<span>
|
|
Score:{' '}
|
|
{project.averageScore !== null
|
|
? project.averageScore.toFixed(1)
|
|
: '-'}
|
|
</span>
|
|
<span>
|
|
{project.evaluationCount} eval
|
|
{project.evaluationCount !== 1 ? 's' : ''}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-muted-foreground">
|
|
Page {projectsData.page} of {projectsData.totalPages} ·{' '}
|
|
{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={cn(
|
|
'flex flex-col items-center justify-center rounded-lg border border-dashed py-16 text-center',
|
|
)}
|
|
>
|
|
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
|
|
<p className="mt-3 font-medium">
|
|
{activeFilterCount > 0 ? 'No projects match your filters' : 'No projects found'}
|
|
</p>
|
|
{activeFilterCount > 0 && (
|
|
<Button variant="ghost" size="sm" className="mt-2" onClick={clearFilters}>
|
|
Clear filters
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<CsvExportDialog
|
|
open={csvOpen}
|
|
onOpenChange={setCsvOpen}
|
|
exportData={csvExportData}
|
|
isLoading={csvLoading}
|
|
filename="observer-projects"
|
|
onRequestData={handleRequestCsvData}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|