fix: security hardening — block self-registration, SSE auth, audit logging fixes
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

Security fixes:
- Block self-registration via magic link (PrismaAdapter createUser throws)
- Magic links only sent to existing ACTIVE users (prevents enumeration)
- signIn callback rejects non-existent users (defense-in-depth)
- Change schema default role from JURY_MEMBER to APPLICANT
- Add authentication to live-voting SSE stream endpoint
- Fix false FILE_OPENED/FILE_DOWNLOADED audit events on page load
  (remove purpose from eagerly pre-fetched URL queries)

Bug fixes:
- Fix impersonation skeleton screen on applicant dashboard
- Fix onboarding redirect loop in auth layout

Observer dashboard redesign (Steps 1-6):
- Clickable round pipeline with selected round highlighting
- Round-type-specific dashboard panels (intake, filtering, evaluation,
  submission, mentoring, live final, deliberation)
- Enhanced activity feed with server-side humanization
- Previous round comparison section
- New backend queries for round-specific analytics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 20:18:50 +01:00
parent 13f125af28
commit 875c2e8f48
23 changed files with 2126 additions and 410 deletions

View File

@@ -0,0 +1,22 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { Lock } from 'lucide-react'
export function DeliberationPanel() {
return (
<div className="flex items-center justify-center min-h-[300px]">
<Card className="max-w-md w-full">
<CardContent className="py-12 text-center">
<div className="relative inline-block mb-4">
<Lock className="h-12 w-12 text-muted-foreground/40 animate-pulse" />
</div>
<h3 className="text-lg font-semibold mb-2">Results Coming Soon</h3>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
The jury is deliberating. Results will be shared when finalized.
</p>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,261 @@
'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 { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { StatusBadge } from '@/components/shared/status-badge'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
Target,
TrendingUp,
Users,
ChevronDown,
ChevronUp,
Award,
} from 'lucide-react'
import { cn } from '@/lib/utils'
export function EvaluationPanel({ roundId, programId }: { roundId: string; programId: string }) {
const [expandedJurorId, setExpandedJurorId] = useState<string | null>(null)
const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: advConfig } = trpc.analytics.getRoundAdvancementConfig.useQuery(
{ roundId },
{ refetchInterval: 60_000 },
)
const { data: dashStats } = trpc.analytics.getDashboardStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: jurorWorkload } = trpc.analytics.getJurorWorkload.useQuery(
{ programId, roundId },
{ enabled: !!programId, refetchInterval: 30_000 },
)
const { data: projectsData } = trpc.analytics.getAllProjects.useQuery(
{ roundId, perPage: 8 },
{ refetchInterval: 30_000 },
)
const stats = roundStats?.stats as {
totalAssignments: number
completedEvaluations: number
completionRate: number
activeJurors: number
} | undefined
const allJurors = jurorWorkload ?? []
const projects = projectsData?.projects ?? []
const scoreDistribution = dashStats?.scoreDistribution ?? []
const maxScoreCount = Math.max(...scoreDistribution.map((b) => b.count), 1)
const scoreColors: Record<string, string> = {
'9-10': '#053d57',
'7-8': '#1e7a8a',
'5-6': '#557f8c',
'3-4': '#c4453a',
'1-2': '#de0f1e',
}
return (
<div className="space-y-4">
{/* Advancement Method Card */}
{advConfig && (
<AnimatedCard index={0}>
<Card className="border-brand-teal/30 bg-brand-teal/5">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-brand-teal/10 p-2">
<Target className="h-5 w-5 text-brand-teal" />
</div>
<div>
<p className="text-sm font-semibold">
{advConfig.advanceMode === 'threshold'
? `Score Threshold ≥ ${advConfig.advanceScoreThreshold ?? '?'}`
: 'Top N Advancement'}
</p>
{advConfig.advanceMode === 'count' && (
<p className="text-xs text-muted-foreground">
{advConfig.startupAdvanceCount != null && `${advConfig.startupAdvanceCount} Startups`}
{advConfig.startupAdvanceCount != null && advConfig.conceptAdvanceCount != null && ', '}
{advConfig.conceptAdvanceCount != null && `${advConfig.conceptAdvanceCount} Business Concepts`}
</p>
)}
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Completion Progress */}
{statsLoading ? (
<Skeleton className="h-20 rounded-lg" />
) : stats ? (
<Card className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Evaluation Progress</span>
<Badge variant="secondary" className="tabular-nums">
{stats.completionRate}%
</Badge>
</div>
<Progress value={stats.completionRate} className="h-2 mb-1" />
<p className="text-xs text-muted-foreground tabular-nums">
{stats.completedEvaluations} / {stats.totalAssignments} evaluations · {stats.activeJurors} jurors
</p>
</Card>
) : null}
{/* Score Distribution */}
{scoreDistribution.length > 0 && (
<AnimatedCard index={1}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<TrendingUp className="h-4 w-4 text-amber-500" />
Score Distribution
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1.5">
{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>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Juror Workload */}
<AnimatedCard index={2}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Users className="h-4 w-4 text-violet-500" />
Juror Workload
</CardTitle>
</CardHeader>
<CardContent>
{allJurors.length > 0 ? (
<div className="max-h-[320px] overflow-y-auto -mr-2 pr-2 space-y-2">
{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">{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>
) : (
<p className="text-sm text-muted-foreground">No juror assignments yet.</p>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Recently Reviewed */}
{projects.length > 0 && (
<AnimatedCard index={3}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Award className="h-4 w-4 text-emerald-500" />
Recently Reviewed
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{projects
.filter((p) => {
const s = p.observerStatus ?? p.status
return s !== 'NOT_REVIEWED' && s !== 'SUBMITTED'
})
.slice(0, 6)
.map((p) => (
<Link
key={p.id}
href={`/observer/projects/${p.id}` as Route}
className="flex items-center justify-between gap-2 px-4 py-2.5 hover:bg-muted/50 transition-colors"
>
<span className="text-sm truncate">{p.title}</span>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={p.observerStatus ?? p.status} size="sm" />
<span className="text-xs tabular-nums text-muted-foreground">
{p.evaluationCount > 0 && p.averageScore !== null
? p.averageScore.toFixed(1)
: '—'}
</span>
</div>
</Link>
))}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
</div>
)
}

View File

@@ -0,0 +1,251 @@
'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 } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Filter, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
export function FilteringPanel({ roundId }: { roundId: string }) {
const [outcomeFilter, setOutcomeFilter] = useState<string>('ALL')
const [page, setPage] = useState(1)
const [expandedId, setExpandedId] = useState<string | null>(null)
const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: resultStats } = trpc.analytics.getFilteringResultStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: results, isLoading: resultsLoading } = trpc.analytics.getFilteringResults.useQuery(
{
roundId,
outcome: outcomeFilter === 'ALL' ? undefined : outcomeFilter as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED',
page,
perPage: 15,
},
{ refetchInterval: 30_000 },
)
const stats = roundStats?.stats as {
totalScreened: number
passed: number
filteredOut: number
flagged: number
passRate: number
} | undefined
const total = stats?.totalScreened ?? 0
const outcomeColor: Record<string, string> = {
PASSED: 'bg-emerald-500',
FILTERED_OUT: 'bg-rose-500',
FLAGGED: 'bg-amber-500',
}
return (
<div className="space-y-4">
{/* Screening Stats Bar */}
{statsLoading ? (
<Skeleton className="h-24 rounded-lg" />
) : stats ? (
<Card className="p-4">
<div className="flex items-center gap-2 mb-3">
<Filter className="h-4 w-4 text-brand-teal" />
<span className="text-sm font-semibold">Screening Results</span>
<Badge variant="secondary" className="ml-auto tabular-nums">{total} screened</Badge>
</div>
{/* Segmented bar */}
{total > 0 && (
<div className="flex h-3 rounded-full overflow-hidden bg-muted">
<div
className="bg-emerald-500 transition-all"
style={{ width: `${(stats.passed / total) * 100}%` }}
title={`Passed: ${stats.passed}`}
/>
<div
className="bg-rose-500 transition-all"
style={{ width: `${(stats.filteredOut / total) * 100}%` }}
title={`Filtered: ${stats.filteredOut}`}
/>
<div
className="bg-amber-500 transition-all"
style={{ width: `${(stats.flagged / total) * 100}%` }}
title={`Flagged: ${stats.flagged}`}
/>
</div>
)}
<div className="flex justify-between mt-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-emerald-500 inline-block" />
Passed {stats.passed}
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-rose-500 inline-block" />
Filtered {stats.filteredOut}
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-amber-500 inline-block" />
Flagged {stats.flagged}
</span>
</div>
</Card>
) : null}
{/* Detailed Stats */}
{resultStats && (
<div className="grid grid-cols-3 gap-3">
<Card className="p-3 text-center">
<p className="text-xl font-semibold tabular-nums text-emerald-600">
{resultStats.passed}
</p>
<p className="text-xs text-muted-foreground">Passed</p>
</Card>
<Card className="p-3 text-center">
<p className="text-xl font-semibold tabular-nums text-amber-600">
{resultStats.overridden}
</p>
<p className="text-xs text-muted-foreground">Overridden</p>
</Card>
<Card className="p-3 text-center">
<p className="text-xl font-semibold tabular-nums">
{resultStats.total > 0 ? Math.round((resultStats.passed / resultStats.total) * 100) : 0}%
</p>
<p className="text-xs text-muted-foreground">Pass Rate</p>
</Card>
</div>
)}
{/* AI Results Table */}
<AnimatedCard index={1}>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm">AI Screening Results</CardTitle>
<Select value={outcomeFilter} onValueChange={(v) => { setOutcomeFilter(v); setPage(1) }}>
<SelectTrigger className="w-32 h-8 text-xs">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All</SelectItem>
<SelectItem value="PASSED">Passed</SelectItem>
<SelectItem value="FILTERED_OUT">Filtered</SelectItem>
<SelectItem value="FLAGGED">Flagged</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="p-0">
{resultsLoading ? (
<div className="p-4 space-y-2">
{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : results && results.results.length > 0 ? (
<>
<div className="divide-y">
{results.results.map((r: any) => (
<div key={r.id}>
<button
type="button"
className="w-full text-left px-4 py-2.5 hover:bg-muted/50 transition-colors"
onClick={() => setExpandedId(expandedId === r.id ? null : r.id)}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<Link
href={`/observer/projects/${r.project?.id}` as Route}
className="text-sm font-medium truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{r.project?.title ?? 'Unknown'}
</Link>
<p className="text-xs text-muted-foreground truncate">
{r.project?.competitionCategory ?? ''} · {r.project?.country ?? ''}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{(() => {
const effectiveOutcome = r.finalOutcome ?? r.outcome
return (
<>
<span className={cn(
'h-2 w-2 rounded-full',
outcomeColor[effectiveOutcome] ?? 'bg-muted',
)} />
<span className="text-xs">{effectiveOutcome?.replace(/_/g, ' ')}</span>
</>
)
})()}
{expandedId === r.id
? <ChevronUp className="h-3 w-3 text-muted-foreground" />
: <ChevronDown className="h-3 w-3 text-muted-foreground" />}
</div>
</div>
</button>
{expandedId === r.id && (
<div className="px-4 pb-3 pt-0">
<div className="rounded bg-muted/50 p-3 text-xs leading-relaxed text-muted-foreground">
{(() => {
const screening = r.aiScreeningJson as Record<string, unknown> | null
const reasoning = (screening?.reasoning ?? screening?.explanation ?? r.overrideReason ?? 'No details available') as string
return reasoning
})()}
</div>
</div>
)}
</div>
))}
</div>
{/* Pagination */}
{results.totalPages > 1 && (
<div className="flex items-center justify-between border-t px-4 py-2">
<span className="text-xs text-muted-foreground">
Page {results.page} of {results.totalPages}
</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
disabled={page >= results.totalPages}
onClick={() => setPage(page + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
) : (
<div className="p-4 text-sm text-muted-foreground">No screening results yet.</div>
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
)
}

View File

@@ -0,0 +1,163 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { AnimatedCard } from '@/components/shared/animated-container'
import { Inbox, Globe, FolderOpen } from 'lucide-react'
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`
}
export function IntakePanel({ roundId, programId }: { roundId: string; programId: string }) {
const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: projectsData } = trpc.analytics.getAllProjects.useQuery(
{ roundId, perPage: 8 },
{ refetchInterval: 30_000 },
)
const { data: geoData } = trpc.analytics.getGeographicDistribution.useQuery(
{ programId, roundId },
{ enabled: !!programId, refetchInterval: 30_000 },
)
const stats = roundStats?.stats as {
totalProjects: number
byCategory: { category: string; count: number }[]
byState: { state: string; count: number }[]
} | undefined
const projects = projectsData?.projects ?? []
const topCountries = (geoData ?? []).slice(0, 10)
return (
<div className="space-y-4">
{/* Stats Cards */}
{statsLoading ? (
<div className="grid grid-cols-3 gap-3">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-20 rounded-lg" />)}
</div>
) : stats ? (
<div className="grid grid-cols-3 gap-3">
<Card className="p-3 text-center">
<p className="text-2xl font-semibold tabular-nums">{stats.totalProjects}</p>
<p className="text-xs text-muted-foreground mt-0.5">Total Projects</p>
</Card>
{stats.byCategory.map((c) => (
<Card key={c.category} className="p-3 text-center">
<p className="text-2xl font-semibold tabular-nums">{c.count}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">{c.category}</p>
</Card>
))}
</div>
) : null}
{/* Recent Submissions */}
<AnimatedCard index={1}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Inbox className="h-4 w-4 text-brand-teal" />
Recent Submissions
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{projects.length > 0 ? (
<div className="divide-y">
{projects.map((p) => (
<Link
key={p.id}
href={`/observer/projects/${p.id}` as Route}
className="flex items-center justify-between gap-2 px-4 py-2.5 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{p.title}</p>
<p className="text-xs text-muted-foreground truncate">
{p.teamName ?? 'No team'} · {p.country ?? ''}
</p>
</div>
<span className="text-[11px] tabular-nums text-muted-foreground shrink-0">
{p.country ?? ''}
</span>
</Link>
))}
</div>
) : (
<div className="p-4 text-sm text-muted-foreground">No submissions yet.</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Country Ranking */}
{topCountries.length > 0 && (
<AnimatedCard index={2}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Globe className="h-4 w-4 text-blue-500" />
Top Countries
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1.5">
{topCountries.map((c, i) => (
<div key={c.countryCode} className="flex items-center justify-between text-sm">
<span className="truncate">
<span className="text-muted-foreground tabular-nums mr-2">{i + 1}.</span>
{c.countryCode}
</span>
<Badge variant="secondary" className="tabular-nums text-xs">
{c.count}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Category Breakdown */}
{stats && stats.byCategory.length > 0 && (
<AnimatedCard index={3}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<FolderOpen className="h-4 w-4 text-emerald-500" />
Category Breakdown
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
{stats.byCategory.map((c) => {
const pct = stats.totalProjects > 0 ? Math.round((c.count / stats.totalProjects) * 100) : 0
return (
<div key={c.category} className="flex-1 rounded-lg bg-muted p-3 text-center">
<p className="text-lg font-semibold tabular-nums">{pct}%</p>
<p className="text-xs text-muted-foreground truncate">{c.category}</p>
</div>
)
})}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
</div>
)
}

View File

@@ -0,0 +1,164 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { AnimatedCard } from '@/components/shared/animated-container'
import { Radio, Users, Trophy, Eye, EyeOff } from 'lucide-react'
import { cn } from '@/lib/utils'
const SESSION_STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; pulse?: boolean }> = {
NOT_STARTED: { label: 'Not Started', color: 'text-slate-500', bg: 'bg-slate-100 dark:bg-slate-800' },
IN_PROGRESS: { label: 'In Progress', color: 'text-emerald-600', bg: 'bg-emerald-50 dark:bg-emerald-900/20', pulse: true },
PAUSED: { label: 'Paused', color: 'text-amber-600', bg: 'bg-amber-50 dark:bg-amber-900/20' },
COMPLETED: { label: 'Completed', color: 'text-blue-600', bg: 'bg-blue-50 dark:bg-blue-900/20' },
}
export function LiveFinalPanel({ roundId }: { roundId: string }) {
const { data: liveDash, isLoading } = trpc.analytics.getLiveFinalDashboard.useQuery(
{ roundId },
{ refetchInterval: 10_000 },
)
const { data: roundStats } = trpc.analytics.getRoundTypeStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const stats = roundStats?.stats as {
sessionStatus: string
voteCount: number
} | undefined
const sessionStatus = liveDash?.sessionStatus ?? stats?.sessionStatus ?? 'NOT_STARTED'
const statusConfig = SESSION_STATUS_CONFIG[sessionStatus] ?? SESSION_STATUS_CONFIG.NOT_STARTED
const jurors = liveDash?.jurors ?? []
const votedCount = jurors.filter((j: any) => j.hasVoted).length
const standings = liveDash?.standings ?? []
const visibility = liveDash?.observerScoreVisibility ?? 'after_completion'
const scoresVisible = visibility === 'realtime'
|| (visibility === 'after_completion' && sessionStatus === 'COMPLETED')
return (
<div className="space-y-4">
{/* Session Status Card */}
{isLoading ? (
<Skeleton className="h-24 rounded-lg" />
) : (
<Card className={cn('p-5', statusConfig.bg)}>
<div className="flex items-center gap-4">
<div className="relative">
<Radio className={cn('h-8 w-8', statusConfig.color)} />
{statusConfig.pulse && (
<span className="absolute -top-0.5 -right-0.5 h-3 w-3 rounded-full bg-emerald-500 animate-pulse" />
)}
</div>
<div>
<p className={cn('text-lg font-semibold', statusConfig.color)}>
{statusConfig.label}
</p>
<p className="text-sm text-muted-foreground">
{liveDash?.voteCount ?? stats?.voteCount ?? 0} votes cast
</p>
</div>
</div>
</Card>
)}
{/* Vote Count */}
<div className="grid grid-cols-2 gap-3">
<Card className="p-3 text-center">
<p className="text-2xl font-semibold tabular-nums">
{liveDash?.voteCount ?? stats?.voteCount ?? 0}
</p>
<p className="text-xs text-muted-foreground mt-0.5">Total Votes</p>
</Card>
<Card className="p-3 text-center">
<p className="text-2xl font-semibold tabular-nums">
{votedCount}/{jurors.length}
</p>
<p className="text-xs text-muted-foreground mt-0.5">Jurors Voted</p>
</Card>
</div>
{/* Juror Participation */}
{jurors.length > 0 && (
<AnimatedCard index={1}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Users className="h-4 w-4 text-violet-500" />
Juror Participation
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1.5 max-h-[250px] overflow-y-auto">
{jurors.map((j: any) => (
<div key={j.id} className="flex items-center justify-between text-sm py-1">
<span className="truncate">{j.name}</span>
<Badge
variant={j.hasVoted ? 'default' : 'outline'}
className={cn(
'text-xs',
j.hasVoted && 'bg-emerald-500 hover:bg-emerald-600',
)}
>
{j.hasVoted ? 'Voted' : 'Pending'}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Standings / Score Visibility */}
<AnimatedCard index={2}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Trophy className="h-4 w-4 text-amber-500" />
Standings
{scoresVisible ? (
<Eye className="h-3.5 w-3.5 text-emerald-500 ml-auto" />
) : (
<EyeOff className="h-3.5 w-3.5 text-muted-foreground ml-auto" />
)}
</CardTitle>
</CardHeader>
<CardContent>
{scoresVisible && standings.length > 0 ? (
<div className="space-y-2">
{standings.map((s: any, i: number) => (
<div key={s.projectId} className="flex items-center justify-between text-sm py-1">
<div className="flex items-center gap-2 min-w-0">
<span className="text-muted-foreground tabular-nums font-medium w-5 text-right">
{i + 1}.
</span>
<span className="truncate">{s.projectTitle}</span>
</div>
<Badge variant="secondary" className="tabular-nums shrink-0">
{typeof s.score === 'number' ? s.score.toFixed(1) : s.score}
</Badge>
</div>
))}
</div>
) : (
<div className="text-center py-6">
<EyeOff className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
{sessionStatus === 'COMPLETED'
? 'Scores are hidden by admin configuration.'
: 'Scores will be revealed when voting completes.'}
</p>
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
)
}

View File

@@ -0,0 +1,139 @@
'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 } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { AnimatedCard } from '@/components/shared/animated-container'
import { MessageCircle, Users, ChevronDown, ChevronUp } from 'lucide-react'
export function MentoringPanel({ roundId }: { roundId: string }) {
const [expandedMentorId, setExpandedMentorId] = useState<string | null>(null)
const { data: mentoringData, isLoading } = trpc.analytics.getMentoringDashboard.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: roundStats } = trpc.analytics.getRoundTypeStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const stats = roundStats?.stats as {
mentorAssignments: number
totalMessages: number
} | undefined
const assignments = mentoringData?.assignments ?? []
const activeMentors = mentoringData?.activeMentors ?? 0
const totalMentors = mentoringData?.totalMentors ?? 0
return (
<div className="space-y-4">
{/* Stats Cards */}
{isLoading ? (
<div className="grid grid-cols-3 gap-3">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-20 rounded-lg" />)}
</div>
) : (
<div className="grid grid-cols-3 gap-3">
<Card className="p-3 text-center">
<p className="text-xl font-semibold tabular-nums">
{activeMentors}/{totalMentors}
</p>
<p className="text-xs text-muted-foreground mt-0.5">Active Mentors</p>
</Card>
<Card className="p-3 text-center">
<p className="text-xl font-semibold tabular-nums">
{mentoringData?.totalMessages ?? stats?.totalMessages ?? 0}
</p>
<p className="text-xs text-muted-foreground mt-0.5">Messages</p>
</Card>
<Card className="p-3 text-center">
<p className="text-xl font-semibold tabular-nums">
{stats?.mentorAssignments ?? assignments.length}
</p>
<p className="text-xs text-muted-foreground mt-0.5">Assignments</p>
</Card>
</div>
)}
{/* Mentor-Mentee Pairings */}
<AnimatedCard index={1}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Users className="h-4 w-4 text-brand-teal" />
Mentor-Mentee Pairings
</CardTitle>
</CardHeader>
<CardContent>
{assignments.length > 0 ? (
<div className="space-y-2 max-h-[400px] overflow-y-auto -mr-2 pr-2">
{assignments.map((mentor: any) => {
const isExpanded = expandedMentorId === mentor.mentorId
return (
<div key={mentor.mentorId} className="border rounded-lg">
<button
type="button"
className="w-full text-left px-3 py-2.5 hover:bg-muted/50 transition-colors rounded-lg"
onClick={() => setExpandedMentorId(isExpanded ? null : mentor.mentorId)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{mentor.mentorName}</span>
{mentor.projects?.some((p: any) => p.messageCount > 0) && (
<Badge variant="secondary" className="text-[10px] px-1.5">
Recently active
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="tabular-nums text-xs">
{mentor.projects?.length ?? 0} projects
</Badge>
{isExpanded
? <ChevronUp className="h-3 w-3 text-muted-foreground" />
: <ChevronDown className="h-3 w-3 text-muted-foreground" />}
</div>
</div>
</button>
{isExpanded && mentor.projects && (
<div className="border-t divide-y">
{mentor.projects.map((proj: any) => (
<Link
key={proj.id}
href={`/observer/projects/${proj.id}` as Route}
className="flex items-center justify-between gap-2 px-3 py-2 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0 flex-1">
<p className="text-sm truncate">{proj.title}</p>
<p className="text-xs text-muted-foreground truncate">
{proj.teamName ?? ''}
</p>
</div>
<div className="flex items-center gap-1 shrink-0 text-xs text-muted-foreground">
<MessageCircle className="h-3 w-3" />
<span className="tabular-nums">{proj.messageCount}</span>
</div>
</Link>
))}
</div>
)}
</div>
)
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No mentor assignments yet.</p>
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
)
}

View File

@@ -0,0 +1,148 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { AnimatedCard } from '@/components/shared/animated-container'
import { ArrowDown, ChevronDown, ChevronUp, TrendingDown } from 'lucide-react'
import { cn } from '@/lib/utils'
export function PreviousRoundSection({ currentRoundId }: { currentRoundId: string }) {
const [collapsed, setCollapsed] = useState(false)
const { data, isLoading } = trpc.analytics.getPreviousRoundComparison.useQuery(
{ currentRoundId },
{ refetchInterval: 60_000 },
)
if (isLoading) {
return <Skeleton className="h-40 w-full rounded-lg" />
}
if (!data || !data.hasPrevious) {
return null
}
const { previousRound, currentRound, eliminated, categoryBreakdown, countryAttrition } = data
return (
<AnimatedCard index={5}>
<Card>
<CardHeader className="pb-2">
<button
type="button"
className="flex items-center justify-between w-full text-left"
onClick={() => setCollapsed(!collapsed)}
>
<CardTitle className="flex items-center gap-2 text-base">
<div className="rounded-lg bg-rose-500/10 p-1.5">
<TrendingDown className="h-4 w-4 text-rose-500" />
</div>
Compared to Previous Round: {previousRound.name}
</CardTitle>
{collapsed
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronUp className="h-4 w-4 text-muted-foreground" />}
</button>
</CardHeader>
{!collapsed && (
<CardContent className="space-y-4">
{/* Headline Stat */}
<div className="flex items-center gap-3 rounded-lg bg-rose-50 dark:bg-rose-950/20 p-4">
<ArrowDown className="h-6 w-6 text-rose-500 shrink-0" />
<div>
<p className="text-lg font-semibold">
{eliminated} project{eliminated !== 1 ? 's' : ''} eliminated
</p>
<p className="text-sm text-muted-foreground">
{previousRound.projectCount} {currentRound.projectCount}
</p>
</div>
</div>
{/* Category Survival Bars */}
{categoryBreakdown && categoryBreakdown.length > 0 && (
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
By Category
</p>
{categoryBreakdown.map((cat: any) => {
const maxVal = Math.max(cat.previous, 1)
const prevPct = 100
const currPct = (cat.current / maxVal) * 100
return (
<div key={cat.category} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium truncate">{cat.category}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{cat.previous} {cat.current}
<span className="text-rose-500 ml-1">(-{cat.eliminated})</span>
</span>
</div>
<div className="relative h-2.5 rounded-full bg-muted overflow-hidden">
<div
className="absolute inset-y-0 left-0 rounded-full bg-slate-300 dark:bg-slate-600 transition-all"
style={{ width: `${prevPct}%` }}
/>
<div
className="absolute inset-y-0 left-0 rounded-full bg-brand-teal transition-all"
style={{ width: `${currPct}%` }}
/>
</div>
</div>
)
})}
</div>
)}
{/* Country Attrition */}
{countryAttrition && countryAttrition.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
Country Attrition (Top 10)
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
{countryAttrition.map((c: any) => (
<div key={c.country} className="flex items-center justify-between text-sm py-0.5">
<span className="truncate">{c.country}</span>
<Badge variant="destructive" className="tabular-nums text-xs">
-{c.lost}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Score Comparison */}
{previousRound.avgScore != null && currentRound.avgScore != null && (
<div className="grid grid-cols-2 gap-3">
<Card className="p-3 text-center border-muted">
<p className="text-xs text-muted-foreground mb-1">{previousRound.name}</p>
<p className="text-lg font-semibold tabular-nums">
{typeof previousRound.avgScore === 'number'
? previousRound.avgScore.toFixed(1)
: previousRound.avgScore}
</p>
<p className="text-[10px] text-muted-foreground">Avg Score</p>
</Card>
<Card className="p-3 text-center border-brand-teal/30">
<p className="text-xs text-muted-foreground mb-1">{currentRound.name}</p>
<p className="text-lg font-semibold tabular-nums">
{typeof currentRound.avgScore === 'number'
? currentRound.avgScore.toFixed(1)
: currentRound.avgScore}
</p>
<p className="text-[10px] text-muted-foreground">Avg Score</p>
</Card>
</div>
)}
</CardContent>
)}
</Card>
</AnimatedCard>
)
}

View File

@@ -0,0 +1,164 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { AnimatedCard } from '@/components/shared/animated-container'
import { FileText, Upload, Users } from 'lucide-react'
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`
}
const FILE_TYPE_ICONS: Record<string, string> = {
pdf: '📄',
image: '🖼️',
video: '🎥',
default: '📎',
}
function fileIcon(fileType: string | null | undefined): string {
if (!fileType) return FILE_TYPE_ICONS.default
const ft = fileType.toLowerCase()
if (ft.includes('pdf')) return FILE_TYPE_ICONS.pdf
if (ft.includes('image') || ft.includes('png') || ft.includes('jpg') || ft.includes('jpeg')) return FILE_TYPE_ICONS.image
if (ft.includes('video') || ft.includes('mp4')) return FILE_TYPE_ICONS.video
return FILE_TYPE_ICONS.default
}
export function SubmissionPanel({ roundId, programId }: { roundId: string; programId: string }) {
const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: recentFiles } = trpc.analytics.getRecentFiles.useQuery(
{ roundId, limit: 10 },
{ refetchInterval: 30_000 },
)
const { data: projectsData } = trpc.analytics.getAllProjects.useQuery(
{ roundId, perPage: 15 },
{ refetchInterval: 30_000 },
)
const stats = roundStats?.stats as {
totalFiles: number
teamsSubmitted: number
} | undefined
const files = recentFiles ?? []
const projects = projectsData?.projects ?? []
return (
<div className="space-y-4">
{/* Stats Cards */}
{statsLoading ? (
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
</div>
) : stats ? (
<div className="grid grid-cols-2 gap-3">
<Card className="p-3 text-center">
<div className="flex items-center justify-center gap-2">
<Upload className="h-4 w-4 text-violet-500" />
<p className="text-2xl font-semibold tabular-nums">{stats.totalFiles}</p>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Files Uploaded</p>
</Card>
<Card className="p-3 text-center">
<div className="flex items-center justify-center gap-2">
<Users className="h-4 w-4 text-blue-500" />
<p className="text-2xl font-semibold tabular-nums">{stats.teamsSubmitted}</p>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Teams Submitted</p>
</Card>
</div>
) : null}
{/* Recent Document Uploads */}
{files.length > 0 && (
<AnimatedCard index={1}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<FileText className="h-4 w-4 text-violet-500" />
Recent Documents
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{files.map((f: any) => (
<div key={f.id} className="flex items-center gap-3 px-4 py-2.5">
<span className="text-lg shrink-0">
{fileIcon(f.fileType)}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{f.fileName}</p>
<p className="text-xs text-muted-foreground truncate">
<Link
href={`/observer/projects/${f.project?.id}` as Route}
className="hover:underline"
>
{f.project?.title ?? 'Unknown project'}
</Link>
</p>
</div>
<span className="text-[11px] tabular-nums text-muted-foreground shrink-0">
{f.createdAt ? relativeTime(f.createdAt) : ''}
</span>
</div>
))}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Project Teams */}
{projects.length > 0 && (
<AnimatedCard index={2}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Users className="h-4 w-4 text-emerald-500" />
Project Teams
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{projects.map((p) => (
<Link
key={p.id}
href={`/observer/projects/${p.id}` as Route}
className="flex items-center justify-between gap-2 px-4 py-2.5 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{p.title}</p>
<p className="text-xs text-muted-foreground truncate">
{p.teamName ?? 'No team'} · {p.country ?? ''}
</p>
</div>
<Badge variant="outline" className="text-xs shrink-0">
{p.country ?? '—'}
</Badge>
</Link>
))}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
</div>
)
}

View File

@@ -1,8 +1,5 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
@@ -14,36 +11,30 @@ import {
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,
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()
@@ -56,11 +47,7 @@ function relativeTime(date: Date | string): string {
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,
'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
@@ -75,52 +62,6 @@ function computeAvgScore(scoreDistribution: { label: string; count: number }[]):
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',
@@ -128,11 +69,52 @@ const STATUS_BADGE_VARIANT: Record<string, 'default' | 'secondary' | 'outline'>
ROUND_ARCHIVED: 'secondary',
}
export function ObserverDashboardContent({ userName }: { userName?: string }) {
const { programs, selectedProgramId, activeRoundId } = useEditionContext()
const [expandedJurorId, setExpandedJurorId] = useState<string | null>(null)
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' },
}
const roundIdParam = activeRoundId || undefined
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 },
@@ -147,51 +129,19 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
{ 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 },
{ limit: 15, roundId: selectedRoundId || undefined },
{ 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 */}
@@ -227,7 +177,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</Card>
) : null}
{/* Pipeline */}
{/* Clickable Pipeline */}
<AnimatedCard index={6}>
<Card>
<CardHeader className="pb-3">
@@ -237,59 +187,80 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</div>
Competition Pipeline
</CardTitle>
<CardDescription>Round-by-round progression overview</CardDescription>
<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-40 shrink-0 rounded-lg" />
<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 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>
))}
{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>
@@ -298,202 +269,26 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</Card>
</AnimatedCard>
{/* Middle Row */}
{/* Main Content: Round Panel + Activity Feed */}
<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>
{/* 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>
</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 */}
{/* Right: Activity Feed */}
<AnimatedCard index={9}>
<Card className="h-full">
<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">
@@ -503,21 +298,18 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</CardTitle>
<CardDescription>Recent platform events</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="flex-1 overflow-hidden">
{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'
<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">
{humanizeActivity(item)}
{item.description}
</p>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{relativeTime(item.createdAt)}
@@ -542,6 +334,11 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</AnimatedCard>
</div>
{/* Previous Round Comparison */}
{selectedRoundId && (
<PreviousRoundSection currentRoundId={selectedRoundId} />
)}
{/* Full-width Map */}
<AnimatedCard index={11}>
{selectedProgramId ? (

View File

@@ -1,13 +1,22 @@
'use client'
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
import { createContext, useContext, useState, useEffect, useMemo, type ReactNode } from 'react'
import { trpc } from '@/lib/trpc/client'
type RoundInfo = {
id: string
name: string
status: string
competitionId?: string
roundType?: string
sortOrder?: number
}
type Program = {
id: string
name: string | null
year?: number
rounds?: Array<{ id: string; name: string; status: string; competitionId?: string }>
rounds?: RoundInfo[]
}
type EditionContextValue = {
@@ -15,6 +24,13 @@ type EditionContextValue = {
selectedProgramId: string
setSelectedProgramId: (id: string) => void
activeRoundId: string
/** The user-selected round (defaults to best/active round) */
selectedRoundId: string
setSelectedRoundId: (id: string) => void
/** Derived roundType for the selected round */
selectedRoundType: string
/** All rounds for the selected program (sorted by sortOrder) */
rounds: RoundInfo[]
}
const EditionContext = createContext<EditionContextValue | null>(null)
@@ -35,23 +51,37 @@ function findBestRound(rounds: Array<{ id: string; status: string }>): string {
export function EditionProvider({ children }: { children: ReactNode }) {
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) {
setSelectedProgramId(programs[0].id)
}
}, [programs, selectedProgramId])
const typedPrograms = (programs ?? []) as Program[]
const selectedProgram = typedPrograms.find(p => p.id === selectedProgramId)
const rounds = (selectedProgram?.rounds ?? []) as Array<{ id: string; status: string }>
const rounds = useMemo(
() => ((selectedProgram?.rounds ?? []) as RoundInfo[]).slice().sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)),
[selectedProgram?.rounds],
)
const activeRoundId = findBestRound(rounds)
// Auto-select first program
useEffect(() => {
if (typedPrograms.length > 0 && !selectedProgramId) {
setSelectedProgramId(typedPrograms[0].id)
}
}, [typedPrograms, selectedProgramId])
// Auto-select best round when program changes or rounds load
useEffect(() => {
if (rounds.length > 0 && (!selectedRoundId || !rounds.some(r => r.id === selectedRoundId))) {
setSelectedRoundId(findBestRound(rounds))
}
}, [rounds, selectedRoundId])
const selectedRoundType = rounds.find(r => r.id === selectedRoundId)?.roundType ?? ''
return (
<EditionContext.Provider
value={{
@@ -59,6 +89,10 @@ export function EditionProvider({ children }: { children: ReactNode }) {
selectedProgramId,
setSelectedProgramId,
activeRoundId,
selectedRoundId,
setSelectedRoundId,
selectedRoundType,
rounds,
}}
>
{children}