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

364 lines
15 KiB
TypeScript
Raw Normal View History

'use client'
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 { AnimatedCard } from '@/components/shared/animated-container'
import { GeographicSummaryCard } from '@/components/charts/geographic-summary-card'
import { useEditionContext } from '@/components/observer/observer-edition-context'
import {
BarChart3,
Globe,
Activity,
Clock,
CheckCircle,
ClipboardList,
Upload,
Users,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { IntakePanel } from '@/components/observer/dashboard/intake-panel'
import { FilteringPanel } from '@/components/observer/dashboard/filtering-panel'
import { EvaluationPanel } from '@/components/observer/dashboard/evaluation-panel'
import { SubmissionPanel } from '@/components/observer/dashboard/submission-panel'
import { MentoringPanel } from '@/components/observer/dashboard/mentoring-panel'
import { LiveFinalPanel } from '@/components/observer/dashboard/live-final-panel'
import { DeliberationPanel } from '@/components/observer/dashboard/deliberation-panel'
import { PreviousRoundSection } from '@/components/observer/dashboard/previous-round-section'
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 STATUS_BADGE_VARIANT: Record<string, 'default' | 'secondary' | 'outline'> = {
ROUND_ACTIVE: 'default',
ROUND_CLOSED: 'secondary',
ROUND_DRAFT: 'outline',
ROUND_ARCHIVED: 'secondary',
}
const CATEGORY_ICONS: Record<string, { icon: typeof Activity; color: string }> = {
round: { icon: Clock, color: 'text-teal-500' },
evaluation: { icon: CheckCircle, color: 'text-blue-500' },
project: { icon: ClipboardList, color: 'text-emerald-500' },
file: { icon: Upload, color: 'text-violet-500' },
deliberation: { icon: Users, color: 'text-amber-500' },
system: { icon: Activity, color: 'text-slate-400' },
}
function RoundPanel({ roundType, roundId, programId }: { roundType: string; roundId: string; programId: string }) {
switch (roundType) {
case 'INTAKE':
return <IntakePanel roundId={roundId} programId={programId} />
case 'FILTERING':
return <FilteringPanel roundId={roundId} />
case 'EVALUATION':
return <EvaluationPanel roundId={roundId} programId={programId} />
case 'SUBMISSION':
return <SubmissionPanel roundId={roundId} programId={programId} />
case 'MENTORING':
return <MentoringPanel roundId={roundId} />
case 'LIVE_FINAL':
return <LiveFinalPanel roundId={roundId} />
case 'DELIBERATION':
return <DeliberationPanel />
default:
return (
<Card className="p-6 text-center text-muted-foreground">
<p>Select a round to view details.</p>
</Card>
)
}
}
export function ObserverDashboardContent({ userName }: { userName?: string }) {
const {
programs,
selectedProgramId,
selectedRoundId,
setSelectedRoundId,
selectedRoundType,
rounds,
activeRoundId,
} = useEditionContext()
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: geoData } = trpc.analytics.getGeographicDistribution.useQuery(
{ programId: selectedProgramId },
{ enabled: !!selectedProgramId, refetchInterval: 30_000 },
)
const { data: activityFeed } = trpc.analytics.getActivityFeed.useQuery(
{ limit: 15, roundId: selectedRoundId || undefined },
{ refetchInterval: 30_000 },
)
const countryCount = geoData ? geoData.length : 0
const avgScore = stats ? computeAvgScore(stats.scoreDistribution) : '—'
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}
{/* Clickable 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>Click a round to view its details</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-44 shrink-0 rounded-lg" />
))}
</div>
) : roundOverview && roundOverview.rounds.length > 0 ? (
<div className="flex items-stretch gap-0 overflow-x-auto py-1 -my-1">
{roundOverview.rounds.map((round, idx) => {
const isSelected = selectedRoundId === round.roundId
const isActive = round.roundStatus === 'ROUND_ACTIVE'
return (
<div key={round.roundId ?? round.roundName + idx} className="flex items-center">
<button
type="button"
onClick={() => setSelectedRoundId(round.roundId)}
className="text-left focus:outline-none"
>
<Card className={cn(
'w-44 shrink-0 border shadow-sm transition-all cursor-pointer hover:shadow-md',
isSelected && 'ring-2 ring-brand-teal shadow-md',
)}>
<CardContent className="p-3 space-y-2">
<div className="flex items-center gap-1.5">
<p className="text-xs font-semibold leading-tight truncate flex-1" title={round.roundName}>
{round.roundName}
</p>
{isActive && (
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
</span>
)}
</div>
<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>
</button>
{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>
{/* Main Content: Round Panel + Activity Feed */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Left: Round-specific panel */}
<div className="lg:col-span-2">
{selectedRoundId && selectedRoundType ? (
<RoundPanel
roundType={selectedRoundType}
roundId={selectedRoundId}
programId={selectedProgramId}
/>
) : (
<Card className="p-6 text-center text-muted-foreground">
<p>Select a round from the pipeline above.</p>
</Card>
)}
</div>
{/* Right: Activity Feed */}
<AnimatedCard index={9}>
<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-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 className="flex-1 overflow-hidden">
{activityFeed && activityFeed.length > 0 ? (
<div className="max-h-[600px] overflow-y-auto -mr-2 pr-2 space-y-3">
{activityFeed.slice(0, 8).map((item) => {
const iconDef = CATEGORY_ICONS[item.category ?? 'system'] ?? CATEGORY_ICONS.system
const IconComponent = iconDef.icon
const iconColor = iconDef.color
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">
{item.description}
</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>
{/* Previous Round Comparison */}
{selectedRoundId && (
<PreviousRoundSection currentRoundId={selectedRoundId} />
)}
{/* 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>
)
}