Observer platform redesign Phase 4: migrate charts to Tremor, redesign all pages
Some checks failed
Build and Push Docker Image / build (push) Failing after 23s

- Migrate 9 chart components from Nivo to @tremor/react (BarChart, AreaChart, DonutChart, ScatterChart)
- Remove @nivo/*, @react-spring/web dependencies (45 packages removed)
- Redesign dashboard: 6 stat tiles, competition pipeline, score distribution, juror workload, activity feed
- Add new /observer/projects page with search, filters, sorting, pagination, CSV export
- Restructure reports page from 5 tabs to 3 (Progress, Jurors, Scores & Analytics) with per-tab CSV export
- Redesign project detail: breadcrumb nav, score card header, 3-tab layout (Overview/Evaluations/Files)
- Update loading skeletons to match new layouts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 21:45:01 +01:00
parent 77cbc64b33
commit 8125ca6567
24 changed files with 3412 additions and 3401 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,487 @@
'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'
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="ELIGIBLE">Eligible</SelectItem>
<SelectItem value="ASSIGNED">Assigned</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-[260px]">
<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>
)}
</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.status} />
</TableCell>
<TableCell>
{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="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>
<StatusBadge status={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>
<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} &middot;{' '}
{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>
)
}