All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
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>
1141 lines
43 KiB
TypeScript
1141 lines
43 KiB
TypeScript
'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>
|
||
)
|
||
}
|