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

567 lines
24 KiB
TypeScript
Raw Normal View History

'use client'
import { useState } 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 {
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 { useEditionContext } from '@/components/observer/observer-edition-context'
import {
ClipboardList,
BarChart3,
TrendingUp,
Users,
Globe,
ChevronRight,
Activity,
ChevronDown,
ChevronUp,
ArrowRight,
Lock,
Clock,
CheckCircle,
XCircle,
} 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_ICONS: Record<string, { icon: typeof CheckCircle; color: string }> = {
ROUND_ACTIVATED: { icon: Clock, color: 'text-emerald-500' },
ROUND_CLOSED: { icon: Lock, color: 'text-slate-500' },
'round.reopened': { icon: Clock, color: 'text-emerald-500' },
'round.closed': { icon: Lock, color: 'text-slate-500' },
EVALUATION_SUBMITTED: { icon: CheckCircle, color: 'text-blue-500' },
ASSIGNMENT_CREATED: { icon: ArrowRight, color: 'text-violet-500' },
PROJECT_ADVANCED: { icon: ArrowRight, color: 'text-teal-500' },
PROJECT_REJECTED: { icon: XCircle, color: 'text-rose-500' },
RESULT_LOCKED: { icon: Lock, color: 'text-amber-500' },
}
function humanizeActivity(item: { eventType: string; actorName?: string | null; details?: Record<string, unknown> | null }): string {
const actor = item.actorName ?? 'System'
const details = item.details ?? {}
const projectName = (details.projectTitle ?? details.projectName ?? '') as string
const roundName = (details.roundName ?? '') as string
switch (item.eventType) {
case 'EVALUATION_SUBMITTED':
return projectName
? `${actor} submitted a review for ${projectName}`
: `${actor} submitted a review`
case 'ROUND_ACTIVATED':
case 'round.reopened':
return roundName ? `${roundName} was opened` : 'A round was opened'
case 'ROUND_CLOSED':
case 'round.closed':
return roundName ? `${roundName} was closed` : 'A round was closed'
case 'ASSIGNMENT_CREATED':
return projectName
? `${projectName} was assigned to a juror`
: 'A project was assigned'
case 'PROJECT_ADVANCED':
return projectName
? `${projectName} advanced${roundName ? ` to ${roundName}` : ''}`
: 'A project advanced'
case 'PROJECT_REJECTED':
return projectName ? `${projectName} was rejected` : 'A project was rejected'
case 'RESULT_LOCKED':
return roundName ? `Results locked for ${roundName}` : 'Results were locked'
default:
return `${actor}: ${item.eventType.replace(/_/g, ' ').toLowerCase()}`
}
}
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 { programs, selectedProgramId, activeRoundId } = useEditionContext()
const [expandedJurorId, setExpandedJurorId] = useState<string | null>(null)
const roundIdParam = activeRoundId || 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 allJurors = jurorWorkload ?? []
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
const recentlyReviewed = (projectsData?.projects ?? []).filter(
(p) => {
const status = p.observerStatus ?? p.status
return status !== 'REJECTED' && status !== 'NOT_REVIEWED' && status !== 'SUBMITTED'
},
)
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">Welcome, {userName || 'Observer'}</p>
</div>
{/* Stats Strip */}
{statsLoading ? (
<Card className="p-4">
<Skeleton className="h-10 w-full" />
</Card>
) : stats ? (
<Card className="p-0 overflow-hidden">
<div className="grid grid-cols-3 md:grid-cols-6 divide-x divide-border">
{[
{ value: stats.projectCount, label: 'Projects' },
{ value: stats.activeRoundName ?? `${stats.activeRoundCount} Active`, label: 'Active Round', isText: !!stats.activeRoundName },
{ value: avgScore, label: 'Avg Score' },
{ value: `${stats.completionRate}%`, label: 'Completion' },
{ value: stats.jurorCount, label: 'Jurors' },
{ value: countryCount, label: 'Countries' },
].map((stat) => (
<div key={stat.label} className="px-4 py-3.5 text-center">
<p className={`font-semibold leading-tight ${
'isText' in stat && stat.isText ? 'text-sm truncate' : 'text-xl tabular-nums'
}`}>{stat.value}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">{stat.label}</p>
</div>
))}
</div>
</Card>
) : 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">
{/* Left column: Score Distribution + Recently Reviewed stacked */}
<div className="flex flex-col gap-6">
{/* Score Distribution */}
<AnimatedCard index={7}>
<Card>
<CardHeader className="pb-2">
<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>
</CardHeader>
<CardContent>
{stats ? (
<div className="space-y-1.5">
{stats.scoreDistribution.map((bucket) => (
<div key={bucket.label} className="flex items-center gap-2">
<span className="w-8 text-right text-[11px] font-medium tabular-nums text-muted-foreground">
{bucket.label}
</span>
<div className="flex-1 overflow-hidden rounded-full bg-muted" style={{ height: 14 }}>
<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-6 text-right text-[11px] tabular-nums text-muted-foreground">
{bucket.count}
</span>
</div>
))}
</div>
) : (
<div className="space-y-1.5">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Recently Reviewed */}
<AnimatedCard index={10} className="flex-1 flex flex-col">
<Card className="flex-1 flex flex-col">
<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>
Recently Reviewed
</CardTitle>
<CardDescription>Latest project reviews</CardDescription>
</CardHeader>
<CardContent className="p-0">
{recentlyReviewed.length > 0 ? (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right whitespace-nowrap">Score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentlyReviewed.map((project) => (
<TableRow key={project.id}>
<TableCell className="max-w-[140px]">
<Link
href={`/observer/projects/${project.id}` as Route}
className="block truncate text-sm font-medium hover:underline"
title={project.title}
>
{project.title}
</Link>
</TableCell>
<TableCell>
<StatusBadge status={project.observerStatus ?? project.status} size="sm" />
</TableCell>
<TableCell className="text-right tabular-nums text-sm whitespace-nowrap">
{project.evaluationCount > 0 && 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(3)].map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* Juror Workload — scrollable list of all jurors */}
<AnimatedCard index={8}>
<Card className="h-full flex flex-col">
<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>All jurors by assignment</CardDescription>
</CardHeader>
<CardContent className="flex-1 overflow-hidden">
{allJurors.length > 0 ? (
<div className="max-h-[500px] overflow-y-auto -mr-2 pr-2 space-y-3">
{allJurors.map((juror) => {
const isExpanded = expandedJurorId === juror.id
return (
<div key={juror.id}>
<button
type="button"
className="w-full text-left space-y-1 rounded-md px-1 -mx-1 py-1 hover:bg-muted/50 transition-colors"
onClick={() => setExpandedJurorId(isExpanded ? null : juror.id)}
>
<div className="flex items-center justify-between text-sm">
<span className="truncate font-medium" title={juror.name ?? ''}>
{juror.name ?? 'Unknown'}
</span>
<div className="ml-2 flex shrink-0 items-center gap-1.5">
<span className="text-xs tabular-nums text-muted-foreground">
{juror.completionRate}%
</span>
{isExpanded ? (
<ChevronUp className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</div>
</div>
<Progress value={juror.completionRate} className="h-1.5" />
<p className="text-[11px] text-muted-foreground">
{juror.completed} / {juror.assigned} evaluations
</p>
</button>
{isExpanded && juror.projects && (
<div className="ml-1 mt-1 space-y-1 border-l-2 border-muted pl-3">
{juror.projects.map((proj: { id: string; title: string; evalStatus: string }) => (
<Link
key={proj.id}
href={`/observer/projects/${proj.id}` as Route}
className="flex items-center justify-between gap-2 rounded py-1 text-xs hover:underline"
>
<span className="truncate">{proj.title}</span>
<StatusBadge status={proj.evalStatus} size="sm" />
</Link>
))}
</div>
)}
</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>
{/* Activity Feed */}
<AnimatedCard index={9}>
<Card className="h-full">
<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
.filter((item) => !item.eventType.includes('transitioned') && !item.eventType.includes('transition'))
.slice(0, 5)
.map((item) => {
const iconDef = ACTIVITY_ICONS[item.eventType]
const IconComponent = iconDef?.icon ?? Activity
const iconColor = iconDef?.color ?? 'text-slate-400'
return (
<div key={item.id} className="flex items-start gap-3">
<IconComponent className={cn('mt-0.5 h-4 w-4 shrink-0', iconColor)} />
<p className="min-w-0 flex-1 text-sm leading-snug">
{humanizeActivity(item)}
</p>
<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>
{/* Full-width Map */}
<AnimatedCard index={11}>
{selectedProgramId ? (
<GeographicSummaryCard programId={selectedProgramId} />
) : (
<Card>
<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-[300px] w-full rounded-md" />
</CardContent>
</Card>
)}
</AnimatedCard>
</div>
)
}