MOPC-App/src/app/(admin)/admin/reports/page.tsx

452 lines
15 KiB
TypeScript
Raw Normal View History

'use client'
import { useState } 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,
PieChart,
TrendingUp,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import {
ScoreDistributionChart,
EvaluationTimelineChart,
StatusBreakdownChart,
JurorWorkloadChart,
ProjectRankingsChart,
CriteriaScoresChart,
GeographicDistribution,
} from '@/components/charts'
function ReportsOverview() {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
// Flatten rounds from all programs
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programId: p.id, programName: `${p.year} Edition` }))) || []
if (isLoading) {
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>
)
}
if (!rounds || rounds.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileSpreadsheet className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No data to report</p>
<p className="text-sm text-muted-foreground">
Create rounds and assign jury members to generate reports
</p>
</CardContent>
</Card>
)
}
// Calculate totals
const totalProjects = programs?.reduce((acc, p) => acc + (p._count?.rounds || 0), 0) || 0
const totalPrograms = programs?.length || 0
const activeRounds = rounds.filter((r) => r.status === 'ACTIVE').length
return (
<div className="space-y-6">
{/* Quick Stats */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Rounds</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{rounds.length}</div>
<p className="text-xs text-muted-foreground">
{activeRounds} active
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalProjects}</div>
<p className="text-xs text-muted-foreground">Across all rounds</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Rounds</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{activeRounds}</div>
<p className="text-xs text-muted-foreground">Currently active</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Programs</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalPrograms}</div>
<p className="text-xs text-muted-foreground">Total programs</p>
</CardContent>
</Card>
</div>
{/* Rounds Table */}
<Card>
<CardHeader>
<CardTitle>Round Reports</CardTitle>
<CardDescription>
View progress and export data for each round
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Round</TableHead>
<TableHead>Program</TableHead>
<TableHead>Projects</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>-</TableCell>
<TableCell>
<Badge
variant={
round.status === 'ACTIVE'
? 'default'
: round.status === 'CLOSED'
? 'secondary'
: 'outline'
}
>
{round.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" asChild>
<a
href={`/api/export/evaluations?roundId=${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?roundId=${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>
)
}
function RoundAnalytics() {
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
// Flatten rounds from all programs with program name
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programName: `${p.year} Edition` }))) || []
// Set default selected round
if (rounds.length && !selectedRoundId) {
setSelectedRoundId(rounds[0].id)
}
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
const { data: timeline, isLoading: timelineLoading } =
trpc.analytics.getEvaluationTimeline.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
const { data: statusBreakdown, isLoading: statusLoading } =
trpc.analytics.getStatusBreakdown.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
const { data: jurorWorkload, isLoading: workloadLoading } =
trpc.analytics.getJurorWorkload.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
const { data: projectRankings, isLoading: rankingsLoading } =
trpc.analytics.getProjectRankings.useQuery(
{ roundId: selectedRoundId!, limit: 15 },
{ enabled: !!selectedRoundId }
)
const { data: criteriaScores, isLoading: criteriaLoading } =
trpc.analytics.getCriteriaScores.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
const selectedRound = rounds.find((r) => r.id === selectedRoundId)
const { data: geoData, isLoading: geoLoading } =
trpc.analytics.getGeographicDistribution.useQuery(
{ programId: selectedRound?.programId || '', roundId: selectedRoundId! },
{ enabled: !!selectedRoundId && !!selectedRound?.programId }
)
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={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRoundId && (
<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}
totalScores={scoreDistribution.totalScores}
/>
) : 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>
)
}
export default function ReportsPage() {
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">
<TabsList>
<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>
</TabsList>
<TabsContent value="overview">
<ReportsOverview />
</TabsContent>
<TabsContent value="analytics">
<RoundAnalytics />
</TabsContent>
</Tabs>
</div>
)
}