Files
MOPC-Portal/src/components/observer/observer-projects-content.tsx

510 lines
19 KiB
TypeScript
Raw Normal View History

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