fix: security hardening — block self-registration, SSE auth, audit logging fixes
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
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:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user