Files
MOPC-Portal/src/app/(admin)/admin/reports/page.tsx
Matt 230347005c
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
Show 100% progress for closed/archived rounds in Round Pipeline
Closed and archived rounds now always display a full progress bar instead
of relying on the computed completionRate which is 0 for non-evaluation rounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:07:18 +01:00

1141 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
FileSpreadsheet,
Download,
BarChart3,
Users,
ClipboardList,
CheckCircle2,
TrendingUp,
GitCompare,
UserCheck,
Globe,
Layers,
Trophy,
ArrowRight,
Hash,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import {
ScoreDistributionChart,
EvaluationTimelineChart,
StatusBreakdownChart,
JurorWorkloadChart,
ProjectRankingsChart,
CriteriaScoresChart,
GeographicDistribution,
CrossStageComparisonChart,
JurorConsistencyChart,
DiversityMetricsChart,
} from '@/components/charts'
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
import { AnimatedCard } from '@/components/shared/animated-container'
function ReportsOverview() {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
const { data: dashStats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery()
// Flatten stages from all programs
const rounds = programs?.flatMap(p =>
((p.stages ?? []) as Array<{ id: string; name: string; status: string; votingEndAt?: string | Date | null }>).map((s: { id: string; name: string; status: string; votingEndAt?: string | Date | null }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
) || []
// Project reporting scope (default: latest program, all rounds)
const [selectedValue, setSelectedValue] = useState<string | null>(null)
useEffect(() => {
if (programs?.length && !selectedValue) {
setSelectedValue(`all:${programs[0].id}`)
}
}, [programs, selectedValue])
const scopeInput = parseSelection(selectedValue)
const hasScope = !!scopeInput.roundId || !!scopeInput.programId
const { data: projectRankings, isLoading: projectsLoading } =
trpc.analytics.getProjectRankings.useQuery(
{ ...scopeInput, limit: 5000 },
{ enabled: hasScope }
)
if (isLoading || statsLoading) {
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-24" />
</CardContent>
</Card>
))}
</div>
</div>
)
}
const totalPrograms = dashStats?.programCount ?? programs?.length ?? 0
const totalProjects = dashStats?.projectCount ?? 0
const activeRounds = dashStats?.activeRoundCount ?? rounds.filter((r: { status: string }) => r.status === 'ROUND_ACTIVE').length
const jurorCount = dashStats?.jurorCount ?? 0
const submittedEvaluations = dashStats?.submittedEvaluations ?? 0
const totalAssignments = dashStats?.totalAssignments ?? 0
const completionRate = dashStats?.completionRate ?? 0
return (
<div className="space-y-6">
{/* Quick Stats */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Programs</p>
<p className="text-2xl font-bold mt-1">{totalPrograms}</p>
<p className="text-xs text-muted-foreground mt-1">
{activeRounds} active round{activeRounds !== 1 ? 's' : ''}
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
<CheckCircle2 className="h-5 w-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Total Projects</p>
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
<p className="text-xs text-muted-foreground mt-1">Across all programs</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<ClipboardList className="h-5 w-5 text-emerald-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Jury Members</p>
<p className="text-2xl font-bold mt-1">{jurorCount}</p>
<p className="text-xs text-muted-foreground mt-1">Active jurors</p>
</div>
<div className="rounded-xl bg-violet-50 p-3">
<Users className="h-5 w-5 text-violet-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
<p className="text-2xl font-bold mt-1">{submittedEvaluations}</p>
<p className="text-xs text-muted-foreground mt-1">
{totalAssignments > 0
? `${completionRate}% completion rate`
: 'No assignments yet'}
</p>
</div>
<div className="rounded-xl bg-brand-teal/10 p-3">
<BarChart3 className="h-5 w-5 text-brand-teal" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* Score Distribution (if any evaluations exist) */}
{dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<BarChart3 className="h-4 w-4 text-blue-600" />
</div>
Score Distribution
</CardTitle>
<CardDescription>Overall score distribution across all evaluations</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{dashStats.scoreDistribution.map((bucket) => {
const maxCount = Math.max(...dashStats.scoreDistribution.map(b => b.count), 1)
return (
<div key={bucket.label} className="flex items-center gap-3">
<span className="w-10 text-sm font-medium text-right">{bucket.label}</span>
<div className="flex-1">
<Progress value={(bucket.count / maxCount) * 100} className="h-6" gradient />
</div>
<span className="w-8 text-sm text-muted-foreground text-right">{bucket.count}</span>
</div>
)
})}
</div>
</CardContent>
</Card>
)}
{/* Project Reports Summary */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<ClipboardList className="h-4 w-4 text-emerald-600" />
</div>
Project Reports
</CardTitle>
<CardDescription>
Summary dashboard optionally filter to a specific round
</CardDescription>
</div>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[280px]">
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Rounds
</SelectItem>
))}
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="space-y-5">
{projectsLoading ? (
<Skeleton className="h-[300px]" />
) : projectRankings?.length ? (
<>
{/* Summary stats row */}
{(() => {
const evaluated = projectRankings.filter(p => p.averageScore !== null)
const scores = evaluated.map(p => p.averageScore as number)
const avgScore = scores.length ? scores.reduce((a, b) => a + b, 0) / scores.length : 0
const minScore = scores.length ? Math.min(...scores) : 0
const maxScore = scores.length ? Math.max(...scores) : 0
const evalPercent = projectRankings.length ? Math.round((evaluated.length / projectRankings.length) * 100) : 0
const statusCounts = projectRankings.reduce((acc, p) => {
acc[p.status] = (acc[p.status] || 0) + 1
return acc
}, {} as Record<string, number>)
return (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Total Projects</p>
<p className="text-xl font-bold tabular-nums">{projectRankings.length}</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Avg Score</p>
<p className="text-xl font-bold tabular-nums">{avgScore ? avgScore.toFixed(1) : '-'}</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Evaluated</p>
<p className="text-xl font-bold tabular-nums">{evalPercent}%</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Score Range</p>
<p className="text-xl font-bold tabular-nums">
{scores.length ? `${minScore.toFixed(1)}${maxScore.toFixed(1)}` : '-'}
</p>
</div>
</div>
{/* Status breakdown chips */}
{Object.keys(statusCounts).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(statusCounts)
.sort(([, a], [, b]) => b - a)
.map(([status, count]) => (
<Badge key={status} variant="outline" className="gap-1">
<Hash className="h-3 w-3" />
{formatStatusLabel(status)} <span className="font-bold tabular-nums">{count}</span>
</Badge>
))}
</div>
)}
{/* Top 10 ranked table */}
<div>
<p className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5" /> Top 10 by Average Score
</p>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">#</TableHead>
<TableHead>Project</TableHead>
<TableHead className="hidden sm:table-cell">Team</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Evals</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projectRankings.slice(0, 10).map((p, idx) => (
<TableRow key={p.id}>
<TableCell className="text-muted-foreground tabular-nums">{idx + 1}</TableCell>
<TableCell className="font-medium">
<Link href={`/admin/projects/${p.id}`} className="hover:underline">
{p.title}
</Link>
</TableCell>
<TableCell className="hidden sm:table-cell text-muted-foreground">
{p.teamName || '-'}
</TableCell>
<TableCell className="text-right tabular-nums">
{p.averageScore === null ? '-' : p.averageScore.toFixed(2)}
</TableCell>
<TableCell className="text-right tabular-nums">{p.evaluationCount}</TableCell>
<TableCell>
<Badge variant="outline">{formatStatusLabel(p.status)}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* Link to full analytics */}
{projectRankings.length > 10 && (
<div className="flex justify-end">
<Button variant="ghost" size="sm" className="gap-1 text-muted-foreground"
onClick={() => {
const tabs = document.querySelectorAll('[role="tab"]')
const analyticsTab = Array.from(tabs).find(t => t.textContent?.includes('Analytics')) as HTMLElement
analyticsTab?.click()
}}
>
View full rankings in Analytics <ArrowRight className="h-3.5 w-3.5" />
</Button>
</div>
)}
</>
)
})()}
</>
) : (
<div className="flex flex-col items-center justify-center py-10 text-center">
<ClipboardList className="h-10 w-10 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No project report data available for the selected scope yet.
</p>
</div>
)}
</CardContent>
</Card>
{/* Round exports (still available) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<FileSpreadsheet className="h-4 w-4 text-blue-600" />
</div>
Round Exports
</CardTitle>
<CardDescription>
Download round-level evaluations and results
</CardDescription>
</CardHeader>
<CardContent>
{rounds.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<FileSpreadsheet className="h-10 w-10 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No rounds created yet.
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Round</TableHead>
<TableHead>Program</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Export</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rounds.map((round) => (
<TableRow key={round.id}>
<TableCell>
<div>
<p className="font-medium">{round.name}</p>
{round.votingEndAt && (
<p className="text-sm text-muted-foreground">
Ends: {formatDateOnly(round.votingEndAt)}
</p>
)}
</div>
</TableCell>
<TableCell>{round.programName}</TableCell>
<TableCell>
<Badge
variant={
round.status === 'ROUND_ACTIVE'
? 'default'
: round.status === 'ROUND_CLOSED'
? 'secondary'
: 'outline'
}
>
{round.status?.replace('ROUND_', '') || round.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2 flex-wrap">
<Button variant="outline" size="sm" asChild>
<a
href={`/api/export/evaluations?stageId=${round.id}`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="mr-2 h-4 w-4" />
Evaluations
</a>
</Button>
<Button variant="outline" size="sm" asChild>
<a
href={`/api/export/results?stageId=${round.id}`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="mr-2 h-4 w-4" />
Results
</a>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}
// Parse selection value: "all:programId" for edition-wide, or roundId
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
if (!value) return {}
if (value.startsWith('all:')) return { programId: value.slice(4) }
return { roundId: value }
}
// Map raw DB status to display-friendly labels
function formatStatusLabel(status: string): string {
const labels: Record<string, string> = {
ELIGIBLE: 'Special Award',
ASSIGNED: 'Assigned',
PENDING: 'Pending',
IN_PROGRESS: 'In Progress',
PASSED: 'Passed',
REJECTED: 'Rejected',
COMPLETED: 'Completed',
WITHDRAWN: 'Withdrawn',
}
return labels[status] ?? status
}
// Find the best default round: active > last closed > first
function findDefaultRound(rounds: Array<{ id: string; status?: string }>): string | undefined {
const active = rounds.find(r => r.status === 'ROUND_ACTIVE')
if (active) return active.id
const closed = [...rounds].reverse().find(r => r.status === 'ROUND_CLOSED')
if (closed) return closed.id
return rounds[0]?.id
}
function StageAnalytics() {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeStages: true })
// Flatten stages from all programs with program name
const rounds = programs?.flatMap(p =>
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
) || []
// Set default selected stage — prefer active round
useEffect(() => {
if (rounds.length && !selectedValue) {
setSelectedValue(findDefaultRound(rounds) ?? rounds[0].id)
}
}, [rounds.length, selectedValue])
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(
queryInput,
{ enabled: hasSelection }
)
const { data: timeline, isLoading: timelineLoading } =
trpc.analytics.getEvaluationTimeline.useQuery(
queryInput,
{ enabled: hasSelection }
)
const { data: statusBreakdown, isLoading: statusLoading } =
trpc.analytics.getStatusBreakdown.useQuery(
queryInput,
{ enabled: hasSelection }
)
const { data: jurorWorkload, isLoading: workloadLoading } =
trpc.analytics.getJurorWorkload.useQuery(
queryInput,
{ enabled: hasSelection }
)
const { data: projectRankings, isLoading: rankingsLoading } =
trpc.analytics.getProjectRankings.useQuery(
{ ...queryInput, limit: 15 },
{ enabled: hasSelection }
)
const { data: criteriaScores, isLoading: criteriaLoading } =
trpc.analytics.getCriteriaScores.useQuery(
queryInput,
{ enabled: hasSelection }
)
const selectedRound = rounds.find((r) => r.id === selectedValue)
const geoInput = queryInput.programId
? { programId: queryInput.programId }
: { programId: selectedRound?.programId || '', roundId: queryInput.roundId }
const { data: geoData, isLoading: geoLoading } =
trpc.analytics.getGeographicDistribution.useQuery(
geoInput,
{ enabled: hasSelection && !!(geoInput.programId || geoInput.roundId) }
)
if (roundsLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-64" />
<div className="grid gap-6 lg:grid-cols-2">
<Skeleton className="h-[350px]" />
<Skeleton className="h-[350px]" />
</div>
</div>
)
}
if (!rounds?.length) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<BarChart3 className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No rounds available</p>
<p className="text-sm text-muted-foreground">
Create a round to view analytics
</p>
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
{/* Round Selector */}
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Round:</label>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Rounds
</SelectItem>
))}
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{hasSelection && (
<div className="space-y-6">
{/* Row 1: Score Distribution & Status Breakdown */}
<div className="grid gap-6 lg:grid-cols-2">
{scoreLoading ? (
<Skeleton className="h-[350px]" />
) : scoreDistribution ? (
<ScoreDistributionChart
data={scoreDistribution.distribution ?? []}
averageScore={scoreDistribution.averageScore ?? 0}
totalScores={scoreDistribution.totalScores ?? 0}
/>
) : null}
{statusLoading ? (
<Skeleton className="h-[350px]" />
) : statusBreakdown ? (
<StatusBreakdownChart data={statusBreakdown} />
) : null}
</div>
{/* Row 2: Evaluation Timeline */}
{timelineLoading ? (
<Skeleton className="h-[350px]" />
) : timeline?.length ? (
<EvaluationTimelineChart data={timeline} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No evaluation data available yet
</p>
</CardContent>
</Card>
)}
{/* Row 3: Criteria Scores */}
{criteriaLoading ? (
<Skeleton className="h-[350px]" />
) : criteriaScores?.length ? (
<CriteriaScoresChart data={criteriaScores} />
) : null}
{/* Row 4: Juror Workload */}
{workloadLoading ? (
<Skeleton className="h-[450px]" />
) : jurorWorkload?.length ? (
<JurorWorkloadChart data={jurorWorkload} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No juror assignments yet
</p>
</CardContent>
</Card>
)}
{/* Row 5: Project Rankings */}
{rankingsLoading ? (
<Skeleton className="h-[550px]" />
) : projectRankings?.length ? (
<ProjectRankingsChart data={projectRankings} limit={15} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No project scores available yet
</p>
</CardContent>
</Card>
)}
{/* Row 6: Geographic Distribution */}
{geoLoading ? (
<Skeleton className="h-[500px]" />
) : geoData?.length ? (
<GeographicDistribution data={geoData} />
) : null}
</div>
)}
</div>
)
}
function CrossStageTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
) || []
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
const { data: comparison, isLoading: comparisonLoading } =
trpc.analytics.getCrossRoundComparison.useQuery(
{ roundIds: selectedRoundIds },
{ enabled: selectedRoundIds.length >= 2 }
)
const toggleRound = (roundId: string) => {
setSelectedRoundIds((prev) =>
prev.includes(roundId)
? prev.filter((id) => id !== roundId)
: [...prev, roundId]
)
}
if (programsLoading) {
return <Skeleton className="h-[400px]" />
}
return (
<div className="space-y-6">
{/* Stage selector */}
<Card>
<CardHeader>
<CardTitle>Select Stages to Compare</CardTitle>
<CardDescription>
Choose at least 2 stages to compare metrics side by side
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{stages.map((stage) => {
const isSelected = selectedRoundIds.includes(stage.id)
return (
<Badge
key={stage.id}
variant={isSelected ? 'default' : 'outline'}
className="cursor-pointer text-sm py-1.5 px-3"
onClick={() => toggleRound(stage.id)}
>
{stage.name}
</Badge>
)
})}
</div>
{selectedRoundIds.length < 2 && (
<p className="text-sm text-muted-foreground mt-3">
Select at least 2 stages to enable comparison
</p>
)}
</CardContent>
</Card>
{/* Comparison charts */}
{comparisonLoading && selectedRoundIds.length >= 2 && (
<div className="space-y-6">
<Skeleton className="h-[350px]" />
<div className="grid gap-6 lg:grid-cols-2">
<Skeleton className="h-[300px]" />
<Skeleton className="h-[300px]" />
</div>
</div>
)}
{comparison && (
<CrossStageComparisonChart data={comparison as Array<{
roundId: string
roundName: string
projectCount: number
evaluationCount: number
completionRate: number
averageScore: number | null
scoreDistribution: { score: number; count: number }[]
}>} />
)}
</div>
)
}
function JurorConsistencyTab() {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programId: p.id, programName: `${p.year} Edition` }))
) || []
useEffect(() => {
if (stages.length && !selectedValue) {
setSelectedValue(findDefaultRound(stages) ?? stages[0].id)
}
}, [stages.length, selectedValue])
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: consistency, isLoading: consistencyLoading } =
trpc.analytics.getJurorConsistency.useQuery(
queryInput,
{ enabled: hasSelection }
)
if (programsLoading) {
return <Skeleton className="h-[400px]" />
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Stage:</label>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Stages
</SelectItem>
))}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{consistencyLoading && <Skeleton className="h-[400px]" />}
{consistency && (
<JurorConsistencyChart
data={consistency as {
overallAverage: number
jurors: Array<{
userId: string
name: string
email: string
evaluationCount: number
averageScore: number
stddev: number
deviationFromOverall: number
isOutlier: boolean
}>
}}
/>
)}
</div>
)
}
function DiversityTab() {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programId: p.id, programName: `${p.year} Edition` }))
) || []
useEffect(() => {
if (stages.length && !selectedValue) {
setSelectedValue(findDefaultRound(stages) ?? stages[0].id)
}
}, [stages.length, selectedValue])
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: diversity, isLoading: diversityLoading } =
trpc.analytics.getDiversityMetrics.useQuery(
queryInput,
{ enabled: hasSelection }
)
if (programsLoading) {
return <Skeleton className="h-[400px]" />
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Stage:</label>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Stages
</SelectItem>
))}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{diversityLoading && <Skeleton className="h-[400px]" />}
{diversity && (
<DiversityMetricsChart
data={diversity as {
total: number
byCountry: { country: string; count: number; percentage: number }[]
byCategory: { category: string; count: number; percentage: number }[]
byOceanIssue: { issue: string; count: number; percentage: number }[]
byTag: { tag: string; count: number; percentage: number }[]
}}
/>
)}
</div>
)
}
function RoundPipelineTab() {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap(p =>
((p.stages ?? []) as Array<{ id: string; name: string; status: string; type?: string }>).map((s) => ({
...s,
programId: p.id,
programName: `${p.year} Edition`,
}))
) || []
const roundIds = rounds.map(r => r.id)
const { data: comparison, isLoading: comparisonLoading } =
trpc.analytics.getCrossRoundComparison.useQuery(
{ roundIds },
{ enabled: roundIds.length >= 2 }
)
if (isLoading || comparisonLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-24" />)}
</div>
)
}
if (!rounds.length) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Layers className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No rounds available</p>
</CardContent>
</Card>
)
}
const comparisonMap = new Map(
(comparison ?? []).map((c: any) => [c.roundId, c])
)
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Layers className="h-4 w-4 text-violet-600" />
</div>
Round Pipeline
</CardTitle>
<CardDescription>Project flow across competition rounds</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{rounds.map((round, idx) => {
const stats = comparisonMap.get(round.id) as any
const isClosed = round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED'
const progressValue = isClosed ? 100 : (stats?.completionRate ?? 0)
return (
<div key={round.id} className="flex items-center gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium">
{idx + 1}
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{round.name}</p>
<p className="text-xs text-muted-foreground">{round.programName}</p>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="tabular-nums">{stats?.projectCount ?? 0} projects</span>
<span className="tabular-nums">{stats?.evaluationCount ?? 0} evals</span>
<Badge variant={round.status === 'ROUND_ACTIVE' ? 'default' : isClosed ? 'secondary' : 'outline'}>
{round.status?.replace('ROUND_', '') ?? 'DRAFT'}
</Badge>
</div>
</div>
<Progress value={progressValue} className="mt-2 h-2" />
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
</div>
)
}
export default function ReportsPage() {
const [pdfStageId, setPdfStageId] = useState<string | null>(null)
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeStages: true })
const pdfStages = pdfPrograms?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programName: `${p.year} Edition` }))
) || []
useEffect(() => {
if (pdfStages.length && !pdfStageId) {
setPdfStageId(findDefaultRound(pdfStages) ?? pdfStages[0].id)
}
}, [pdfStages.length, pdfStageId])
const selectedPdfStage = pdfStages.find((r) => r.id === pdfStageId)
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Reports</h1>
<p className="text-muted-foreground">
View progress, analytics, and export evaluation data
</p>
</div>
{/* Tabs */}
<Tabs defaultValue="overview" className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<TabsList className="w-full sm:w-auto flex-wrap justify-start overflow-x-auto scrollbar-hide">
<TabsTrigger value="overview" className="gap-2">
<FileSpreadsheet className="h-4 w-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics" className="gap-2">
<TrendingUp className="h-4 w-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="cross-stage" className="gap-2">
<GitCompare className="h-4 w-4" />
Cross-Round
</TabsTrigger>
<TabsTrigger value="consistency" className="gap-2">
<UserCheck className="h-4 w-4" />
Juror Consistency
</TabsTrigger>
<TabsTrigger value="diversity" className="gap-2">
<Globe className="h-4 w-4" />
Diversity
</TabsTrigger>
<TabsTrigger value="pipeline" className="gap-2">
<Layers className="h-4 w-4" />
By Round
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2 w-full sm:w-auto justify-between sm:justify-end">
<Select value={pdfStageId || ''} onValueChange={setPdfStageId}>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="Select stage for PDF" />
</SelectTrigger>
<SelectContent>
{pdfStages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
{pdfStageId && (
<ExportPdfButton
roundId={pdfStageId}
roundName={selectedPdfStage?.name}
programName={selectedPdfStage?.programName}
/>
)}
</div>
</div>
<TabsContent value="overview">
<ReportsOverview />
</TabsContent>
<TabsContent value="analytics">
<StageAnalytics />
</TabsContent>
<TabsContent value="cross-stage">
<CrossStageTab />
</TabsContent>
<TabsContent value="consistency">
<JurorConsistencyTab />
</TabsContent>
<TabsContent value="diversity">
<DiversityTab />
</TabsContent>
<TabsContent value="pipeline">
<RoundPipelineTab />
</TabsContent>
</Tabs>
</div>
)
}