Files
MOPC-Portal/src/components/observer/observer-projects-content.tsx
Matt 267d26581d
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m30s
feat: resolve project logo URLs server-side, show logos in admin + observer
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>
2026-03-04 13:29:54 +01:00

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} &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>
)
}