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

597 lines
24 KiB
TypeScript
Raw Normal View History

'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { StatusBadge } from '@/components/shared/status-badge'
import { AnimatedCard } from '@/components/shared/animated-container'
import { GeographicSummaryCard } from '@/components/charts/geographic-summary-card'
import {
ClipboardList,
BarChart3,
TrendingUp,
CheckCircle2,
Users,
Globe,
ChevronRight,
Activity,
RefreshCw,
} from 'lucide-react'
import { cn } from '@/lib/utils'
function relativeTime(date: Date | string): string {
const now = Date.now()
const then = new Date(date).getTime()
const diff = Math.floor((now - then) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
function computeAvgScore(scoreDistribution: { label: string; count: number }[]): string {
const midpoints: Record<string, number> = {
'9-10': 9.5,
'7-8': 7.5,
'5-6': 5.5,
'3-4': 3.5,
'1-2': 1.5,
}
let total = 0
let weightedSum = 0
for (const b of scoreDistribution) {
const mid = midpoints[b.label]
if (mid !== undefined) {
weightedSum += mid * b.count
total += b.count
}
}
if (total === 0) return '—'
return (weightedSum / total).toFixed(1)
}
const ACTIVITY_DOT_COLORS: Record<string, string> = {
ROUND_ACTIVATED: 'bg-emerald-500',
ROUND_CLOSED: 'bg-slate-500',
EVALUATION_SUBMITTED: 'bg-blue-500',
ASSIGNMENT_CREATED: 'bg-violet-500',
PROJECT_ADVANCED: 'bg-teal-500',
PROJECT_REJECTED: 'bg-rose-500',
RESULT_LOCKED: 'bg-amber-500',
}
const STATUS_BADGE_VARIANT: Record<string, 'default' | 'secondary' | 'outline'> = {
ROUND_ACTIVE: 'default',
ROUND_CLOSED: 'secondary',
ROUND_DRAFT: 'outline',
ROUND_ARCHIVED: 'secondary',
}
export function ObserverDashboardContent({ userName }: { userName?: string }) {
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const { data: programs } = trpc.program.list.useQuery(
{ includeStages: true },
{ refetchInterval: 30_000 },
)
useEffect(() => {
if (programs && programs.length > 0 && !selectedProgramId) {
const firstProgram = programs[0]
setSelectedProgramId(firstProgram.id)
const firstRound = (firstProgram.rounds ?? [])[0]
if (firstRound) setSelectedRoundId(firstRound.id)
}
}, [programs, selectedProgramId])
const roundIdParam = selectedRoundId || undefined
const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery(
{ roundId: roundIdParam },
{ refetchInterval: 30_000 },
)
const selectedProgram = programs?.find((p) => p.id === selectedProgramId)
const competitionId = (selectedProgram?.rounds ?? [])[0]?.competitionId as string | undefined
const { data: roundOverview, isLoading: overviewLoading } = trpc.analytics.getRoundCompletionOverview.useQuery(
{ competitionId: competitionId! },
{ enabled: !!competitionId, refetchInterval: 30_000 },
)
const { data: jurorWorkload } = trpc.analytics.getJurorWorkload.useQuery(
{ programId: selectedProgramId || undefined },
{ enabled: !!selectedProgramId, refetchInterval: 30_000 },
)
const { data: geoData } = trpc.analytics.getGeographicDistribution.useQuery(
{ programId: selectedProgramId },
{ enabled: !!selectedProgramId, refetchInterval: 30_000 },
)
const { data: projectsData } = trpc.analytics.getAllProjects.useQuery(
{ perPage: 10 },
{ refetchInterval: 30_000 },
)
const { data: activityFeed } = trpc.analytics.getActivityFeed.useQuery(
{ limit: 10 },
{ refetchInterval: 30_000 },
)
const countryCount = geoData ? geoData.length : 0
const avgScore = stats ? computeAvgScore(stats.scoreDistribution) : '—'
const topJurors = (jurorWorkload ?? []).slice(0, 5)
const scoreColors: Record<string, string> = {
'9-10': '#053d57',
'7-8': '#1e7a8a',
'5-6': '#557f8c',
'3-4': '#c4453a',
'1-2': '#de0f1e',
}
const maxScoreCount = stats
? Math.max(...stats.scoreDistribution.map((b) => b.count), 1)
: 1
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">Welcome, {userName || 'Observer'}</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<div className="flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
Auto-refresh
</div>
<Select
value={selectedProgramId}
onValueChange={(val) => {
setSelectedProgramId(val)
const prog = programs?.find((p) => p.id === val)
const firstRound = (prog?.rounds ?? [])[0]
setSelectedRoundId(firstRound?.id ?? '')
}}
>
<SelectTrigger className="w-full sm:w-[220px]">
<SelectValue placeholder="Select edition" />
</SelectTrigger>
<SelectContent>
{(programs ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{(p as { year?: number }).year ? `${(p as { year?: number }).year} Edition` : p.name ?? p.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Six Stat Tiles */}
{statsLoading ? (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
{[...Array(6)].map((_, i) => (
<Card key={i} className="p-4">
<Skeleton className="h-8 w-8 rounded-lg mb-3" />
<Skeleton className="h-7 w-16 mb-1" />
<Skeleton className="h-3 w-20" />
</Card>
))}
</div>
) : stats ? (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
<AnimatedCard index={0}>
<Card className="group cursor-default p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<div className="mb-3 inline-flex rounded-lg bg-emerald-100 p-2">
<ClipboardList className="h-5 w-5 text-emerald-600" />
</div>
<p className="text-2xl font-bold tabular-nums">{stats.projectCount}</p>
<p className="text-xs text-muted-foreground">Total Projects</p>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="group cursor-default p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<div className="mb-3 inline-flex rounded-lg bg-blue-100 p-2">
<BarChart3 className="h-5 w-5 text-blue-600" />
</div>
<p className="text-2xl font-bold tabular-nums">{stats.activeRoundCount}</p>
<p className="text-xs text-muted-foreground">Active Rounds</p>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="group cursor-default p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<div className="mb-3 inline-flex rounded-lg bg-amber-100 p-2">
<TrendingUp className="h-5 w-5 text-amber-600" />
</div>
<p className="text-2xl font-bold tabular-nums">{avgScore}</p>
<p className="text-xs text-muted-foreground">Avg Score</p>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="group cursor-default p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<div className="mb-3 inline-flex rounded-lg bg-teal-100 p-2">
<CheckCircle2 className="h-5 w-5 text-teal-600" />
</div>
<p className="text-2xl font-bold tabular-nums">{stats.completionRate}%</p>
<div className="mt-1">
<Progress value={stats.completionRate} className="h-1.5" />
</div>
<p className="mt-1 text-xs text-muted-foreground">Completion</p>
</Card>
</AnimatedCard>
<AnimatedCard index={4}>
<Card className="group cursor-default p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<div className="mb-3 inline-flex rounded-lg bg-violet-100 p-2">
<Users className="h-5 w-5 text-violet-600" />
</div>
<p className="text-2xl font-bold tabular-nums">{stats.jurorCount}</p>
<p className="text-xs text-muted-foreground">Active Jurors</p>
</Card>
</AnimatedCard>
<AnimatedCard index={5}>
<Card className="group cursor-default p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<div className="mb-3 inline-flex rounded-lg bg-rose-100 p-2">
<Globe className="h-5 w-5 text-rose-600" />
</div>
<p className="text-2xl font-bold tabular-nums">{countryCount}</p>
<p className="text-xs text-muted-foreground">Countries</p>
</Card>
</AnimatedCard>
</div>
) : null}
{/* Pipeline */}
<AnimatedCard index={6}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2.5 text-base">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<BarChart3 className="h-4 w-4 text-blue-500" />
</div>
Competition Pipeline
</CardTitle>
<CardDescription>Round-by-round progression overview</CardDescription>
</CardHeader>
<CardContent>
{overviewLoading || !competitionId ? (
<div className="flex gap-4 overflow-x-auto pb-2">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-32 w-40 shrink-0 rounded-lg" />
))}
</div>
) : roundOverview && roundOverview.rounds.length > 0 ? (
<div className="flex items-stretch gap-0 overflow-x-auto pb-2">
{roundOverview.rounds.map((round, idx) => (
<div key={round.roundName + idx} className="flex items-center">
<Card className="w-44 shrink-0 border shadow-sm">
<CardContent className="p-3 space-y-2">
<p className="text-xs font-semibold leading-tight truncate" title={round.roundName}>
{round.roundName}
</p>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{round.roundType.replace(/_/g, ' ')}
</Badge>
<Badge
variant={STATUS_BADGE_VARIANT[round.roundStatus] ?? 'outline'}
className="text-[10px] px-1.5 py-0"
>
{round.roundStatus === 'ROUND_ACTIVE'
? 'Active'
: round.roundStatus === 'ROUND_CLOSED'
? 'Closed'
: round.roundStatus === 'ROUND_DRAFT'
? 'Draft'
: round.roundStatus === 'ROUND_ARCHIVED'
? 'Archived'
: round.roundStatus}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{round.totalProjects} project{round.totalProjects !== 1 ? 's' : ''}
</p>
<div className="space-y-1">
<Progress value={round.completionRate} className="h-1.5" />
<p className="text-[10px] text-muted-foreground tabular-nums">
{round.completionRate}% complete
</p>
</div>
</CardContent>
</Card>
{idx < roundOverview.rounds.length - 1 && (
<div className="h-px w-6 shrink-0 border-t-2 border-brand-teal" />
)}
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No round data available for this competition.</p>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Middle Row */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Score Distribution */}
<AnimatedCard index={7}>
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2.5 text-base">
<div className="rounded-lg bg-amber-500/10 p-1.5">
<TrendingUp className="h-4 w-4 text-amber-500" />
</div>
Score Distribution
</CardTitle>
<CardDescription>Evaluation score buckets</CardDescription>
</CardHeader>
<CardContent>
{stats ? (
<div className="space-y-2.5">
{stats.scoreDistribution.map((bucket) => (
<div key={bucket.label} className="flex items-center gap-3">
<span className="w-10 text-right text-xs font-medium tabular-nums text-muted-foreground">
{bucket.label}
</span>
<div className="flex-1 overflow-hidden rounded-full bg-muted" style={{ height: 20 }}>
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${maxScoreCount > 0 ? (bucket.count / maxScoreCount) * 100 : 0}%`,
backgroundColor: scoreColors[bucket.label] ?? '#557f8c',
}}
/>
</div>
<span className="w-8 text-right text-xs tabular-nums text-muted-foreground">
{bucket.count}
</span>
</div>
))}
</div>
) : (
<div className="space-y-2.5">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Juror Workload */}
<AnimatedCard index={8}>
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2.5 text-base">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Users className="h-4 w-4 text-violet-500" />
</div>
Juror Workload
</CardTitle>
<CardDescription>Top 5 jurors by assignment</CardDescription>
</CardHeader>
<CardContent>
{topJurors.length > 0 ? (
<div className="space-y-4">
{topJurors.map((juror) => (
<div key={juror.id} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="truncate font-medium" title={juror.name ?? ''}>
{juror.name ?? 'Unknown'}
</span>
<span className="ml-2 shrink-0 text-xs tabular-nums text-muted-foreground">
{juror.completionRate}%
</span>
</div>
<Progress value={juror.completionRate} className="h-1.5" />
<p className="text-[11px] text-muted-foreground">
{juror.completed} / {juror.assigned} evaluations
</p>
</div>
))}
</div>
) : (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="space-y-1">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-1.5 w-full" />
</div>
))}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Project Origins */}
<AnimatedCard index={9}>
{selectedProgramId ? (
<GeographicSummaryCard programId={selectedProgramId} />
) : (
<Card className="h-full">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Globe className="h-5 w-5" />
Project Origins
</CardTitle>
<CardDescription>Geographic distribution of projects</CardDescription>
</CardHeader>
<CardContent>
<Skeleton className="h-[250px] w-full rounded-md" />
</CardContent>
</Card>
)}
</AnimatedCard>
</div>
{/* Bottom Row */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Recent Projects Table */}
<AnimatedCard index={10}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2.5 text-base">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<ClipboardList className="h-4 w-4 text-emerald-500" />
</div>
Recent Projects
</CardTitle>
<CardDescription>Latest project activity</CardDescription>
</CardHeader>
<CardContent className="p-0">
{projectsData && projectsData.projects.length > 0 ? (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projectsData.projects.map((project) => (
<TableRow key={project.id}>
<TableCell className="max-w-[180px]">
<Link
href={`/observer/projects/${project.id}` as Route}
className="block truncate text-sm font-medium hover:underline"
title={project.title}
>
{project.title}
</Link>
{project.teamName && (
<p className="truncate text-[11px] text-muted-foreground">{project.teamName}</p>
)}
</TableCell>
<TableCell>
<StatusBadge status={project.status} />
</TableCell>
<TableCell className="text-right tabular-nums text-sm">
{project.averageScore !== null ? project.averageScore.toFixed(1) : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="border-t px-4 py-3">
<Link
href={"/observer/projects" as Route}
className="flex items-center gap-1 text-sm font-medium text-brand-teal hover:underline"
>
View All <ChevronRight className="h-4 w-4" />
</Link>
</div>
</>
) : (
<div className="space-y-2 p-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Activity Feed */}
<AnimatedCard index={11}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2.5 text-base">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<Activity className="h-4 w-4 text-blue-500" />
</div>
Activity Feed
</CardTitle>
<CardDescription>Recent platform events</CardDescription>
</CardHeader>
<CardContent>
{activityFeed && activityFeed.length > 0 ? (
<div className="space-y-3">
{activityFeed.map((item) => (
<div key={item.id} className="flex items-start gap-3">
<span
className={cn(
'mt-1.5 h-2 w-2 shrink-0 rounded-full',
ACTIVITY_DOT_COLORS[item.eventType] ?? 'bg-slate-400',
)}
/>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug">
<span className="font-medium">
{item.eventType.replace(/_/g, ' ').toLowerCase().replace(/^\w/, (c) => c.toUpperCase())}
</span>
{item.entityType && (
<span className="text-muted-foreground"> {item.entityType.replace(/_/g, ' ').toLowerCase()}</span>
)}
</p>
{item.actorName && (
<p className="text-[11px] text-muted-foreground">by {item.actorName}</p>
)}
</div>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{relativeTime(item.createdAt)}
</span>
</div>
))}
</div>
) : (
<div className="space-y-3">
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-2 w-2 rounded-full" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-3 w-12" />
</div>
))}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
</div>
)
}