Redesign admin dashboard: pipeline view, round-specific stats, smart actions
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m18s

Complete rewrite of the admin dashboard replacing the 1056-line monolith with
modular components. Key improvements:

- Competition pipeline: horizontal visualization of all rounds in pipeline order
  (by sortOrder, not creation date) with type-specific icons and metrics
- Round-specific stats: stat cards dynamically change based on active round type
  (INTAKE shows submissions/docs, FILTERING shows pass/fail/flagged, EVALUATION
  shows assignments/completion, fallback shows generic project/jury stats)
- Smart actions: context-aware "Action Required" panel that only flags the next
  draft round (not all), only flags unassigned projects for EVALUATION rounds,
  includes deadline warnings with severity levels (critical/warning/info)
- Active round panel: detailed view with project state bar, type-specific content,
  and deadline countdown
- Extracted components: project list, activity feed, category breakdown, skeleton

Backend enriched with 17 parallel queries building rich PipelineRound data
including per-round project states, eval stats, filtering stats, live session
status, and deliberation counts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-17 11:12:28 +01:00
parent 842e79e319
commit 1a0525c108
17 changed files with 2367 additions and 1025 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,302 @@
'use client'
import Link from 'next/link'
import { motion } from 'motion/react'
import {
Inbox,
Filter,
ClipboardCheck,
Upload,
Users,
Radio,
Scale,
Clock,
ArrowRight,
} from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { StatusBadge } from '@/components/shared/status-badge'
import { cn, formatEnumLabel, daysUntil } from '@/lib/utils'
export type PipelineRound = {
id: string
name: string
roundType: string
status: string
projectStates: {
PENDING: number
IN_PROGRESS: number
PASSED: number
REJECTED: number
COMPLETED: number
WITHDRAWN: number
total: number
}
assignmentCount: number
evalSubmitted: number
evalDraft: number
evalTotal: number
filteringPassed: number
filteringRejected: number
filteringFlagged: number
filteringTotal: number
liveSessionStatus: string | null
deliberationCount: number
windowOpenAt: Date | null
windowCloseAt: Date | null
sortOrder: number
slug: string
}
type ActiveRoundPanelProps = {
round: PipelineRound
}
const roundTypeIcons: Record<string, React.ElementType> = {
INTAKE: Inbox,
FILTERING: Filter,
EVALUATION: ClipboardCheck,
SUBMISSION: Upload,
MENTORING: Users,
LIVE_FINAL: Radio,
DELIBERATION: Scale,
}
const stateColors: Record<string, { bg: string; label: string }> = {
PENDING: { bg: 'bg-slate-300', label: 'Pending' },
IN_PROGRESS: { bg: 'bg-blue-400', label: 'In Progress' },
PASSED: { bg: 'bg-emerald-500', label: 'Passed' },
REJECTED: { bg: 'bg-red-400', label: 'Rejected' },
COMPLETED: { bg: 'bg-[#557f8c]', label: 'Completed' },
WITHDRAWN: { bg: 'bg-slate-400', label: 'Withdrawn' },
}
function DeadlineCountdown({ date }: { date: Date }) {
const days = daysUntil(date)
if (days < 0) {
return (
<Badge variant="secondary" className="gap-1">
<Clock className="h-3 w-3" />
Closed
</Badge>
)
}
return (
<Badge
variant={days <= 2 ? 'destructive' : days <= 7 ? 'warning' : 'info'}
className="gap-1"
>
<Clock className="h-3 w-3" />
{days === 0 ? 'Closes today' : `${days}d left`}
</Badge>
)
}
function ProjectStateBar({
projectStates,
}: {
projectStates: PipelineRound['projectStates']
}) {
const total = projectStates.total
if (total === 0) return null
const segments = (
['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'] as const
).filter((key) => projectStates[key] > 0)
return (
<TooltipProvider>
<div className="space-y-2">
<div className="flex h-3 w-full overflow-hidden rounded-full bg-muted">
{segments.map((key) => {
const pct = (projectStates[key] / total) * 100
const color = stateColors[key]
return (
<Tooltip key={key}>
<TooltipTrigger asChild>
<motion.div
className={cn('h-full', color.bg)}
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.6, ease: 'easeOut' }}
/>
</TooltipTrigger>
<TooltipContent>
{color.label}: {projectStates[key]} ({Math.round(pct)}%)
</TooltipContent>
</Tooltip>
)
})}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1">
{segments.map((key) => {
const color = stateColors[key]
return (
<div key={key} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={cn('inline-block h-2 w-2 rounded-full', color.bg)} />
{color.label}: {projectStates[key]}
</div>
)
})}
</div>
</div>
</TooltipProvider>
)
}
function RoundTypeContent({ round }: { round: PipelineRound }) {
const { projectStates } = round
switch (round.roundType) {
case 'INTAKE':
return (
<p className="text-sm text-muted-foreground">
Projects are submitting documents. {projectStates.PASSED} auto-passed,{' '}
{projectStates.PENDING} pending.
</p>
)
case 'FILTERING': {
const processed = round.filteringPassed + round.filteringRejected + round.filteringFlagged
const total = round.filteringTotal
const pct = total > 0 ? Math.round((processed / total) * 100) : 0
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Filtering progress</span>
<span className="font-medium">{pct}%</span>
</div>
<Progress value={pct} gradient />
<div className="flex gap-4 text-xs text-muted-foreground">
<span className="text-emerald-600">{round.filteringPassed} passed</span>
<span className="text-red-600">{round.filteringRejected} failed</span>
<span className="text-amber-600">{round.filteringFlagged} flagged</span>
</div>
</div>
)
}
case 'EVALUATION': {
const pct =
round.evalTotal > 0
? Math.round((round.evalSubmitted / round.evalTotal) * 100)
: 0
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Evaluation progress</span>
<span className="font-medium">
{round.evalSubmitted} / {round.evalTotal} ({pct}%)
</span>
</div>
<Progress value={pct} gradient />
{round.evalDraft > 0 && (
<p className="text-xs text-amber-600">
{round.evalDraft} draft{round.evalDraft !== 1 ? 's' : ''} in progress
</p>
)}
</div>
)
}
case 'SUBMISSION':
return (
<p className="text-sm text-muted-foreground">
{projectStates.COMPLETED} submissions completed, {projectStates.IN_PROGRESS} in
progress, {projectStates.PENDING} pending.
</p>
)
case 'MENTORING':
return (
<p className="text-sm text-muted-foreground">
Mentoring phase active. {round.assignmentCount} mentor assignment
{round.assignmentCount !== 1 ? 's' : ''} configured.{' '}
{projectStates.COMPLETED} projects completed mentoring.
</p>
)
case 'LIVE_FINAL':
return (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
Live finals round.{' '}
{round.liveSessionStatus
? `Session: ${formatEnumLabel(round.liveSessionStatus)}`
: 'No session started yet.'}
</p>
<p className="text-xs text-muted-foreground">
{projectStates.total} projects in round, {projectStates.COMPLETED} completed.
</p>
</div>
)
case 'DELIBERATION':
return (
<p className="text-sm text-muted-foreground">
Deliberation phase.{' '}
{round.deliberationCount > 0
? `${round.deliberationCount} deliberation session${round.deliberationCount !== 1 ? 's' : ''} recorded.`
: 'No deliberation sessions yet.'}
</p>
)
default:
return (
<p className="text-sm text-muted-foreground">
{projectStates.total} projects in this round.
</p>
)
}
}
export function ActiveRoundPanel({ round }: ActiveRoundPanelProps) {
const Icon = roundTypeIcons[round.roundType] || ClipboardCheck
return (
<Card>
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-brand-blue/10">
<Icon className="h-5 w-5 text-brand-blue" />
</div>
<div className="min-w-0 flex-1">
<CardTitle className="truncate">{round.name}</CardTitle>
<p className="mt-0.5 text-xs text-muted-foreground">
{formatEnumLabel(round.roundType)}
</p>
</div>
<div className="flex items-center gap-2">
<StatusBadge status={round.status} size="sm" />
{round.windowCloseAt && (
<DeadlineCountdown date={new Date(round.windowCloseAt)} />
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<ProjectStateBar projectStates={round.projectStates} />
<RoundTypeContent round={round} />
<div className="pt-1">
<Button asChild size="sm">
<Link href={`/admin/rounds/${round.id}`}>
Manage Round
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import { motion } from 'motion/react'
import { Activity } from 'lucide-react'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { formatRelativeTime } from '@/lib/utils'
import { formatAction, getActionIcon } from '@/components/dashboard/utils'
type ActivityFeedProps = {
activity: Array<{
id: string
action: string
entityType: string | null
timestamp: Date
user: { name: string | null } | null
}>
}
export function ActivityFeed({ activity }: ActivityFeedProps) {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10">
<Activity className="h-4 w-4 text-brand-blue" />
</div>
<CardTitle className="text-base">Activity</CardTitle>
</div>
</CardHeader>
<CardContent>
{activity.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Activity className="h-8 w-8 text-muted-foreground/30" />
<p className="mt-2 text-xs text-muted-foreground">
No recent activity
</p>
</div>
) : (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-[13px] top-2 bottom-2 w-px bg-border" />
<div className="space-y-3">
{activity.map((log, idx) => (
<motion.div
key={log.id}
initial={{ opacity: 0, x: 6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: 0.2 + idx * 0.03 }}
className="relative flex items-start gap-3"
>
<div className="relative z-10 flex h-[26px] w-[26px] shrink-0 items-center justify-center rounded-full border-2 border-background bg-muted">
{getActionIcon(log.action)}
</div>
<div className="flex-1 min-w-0 pt-0.5">
<p className="text-xs leading-relaxed">
<span className="font-semibold">{log.user?.name || 'System'}</span>
{' '}{formatAction(log.action, log.entityType)}
</p>
<p className="text-[11px] text-muted-foreground">
{formatRelativeTime(log.timestamp)}
</p>
</div>
</motion.div>
))}
</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,110 @@
'use client'
import { motion } from 'motion/react'
import { Layers } from 'lucide-react'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { formatEnumLabel } from '@/lib/utils'
type CategoryBreakdownProps = {
categories: Array<{ competitionCategory: string | null; _count: number }>
issues: Array<{ oceanIssue: string | null; _count: number }>
}
export function CategoryBreakdown({ categories: rawCategories, issues: rawIssues }: CategoryBreakdownProps) {
const categories = rawCategories
.filter((c) => c.competitionCategory !== null)
.map((c) => ({
label: formatEnumLabel(c.competitionCategory!),
count: c._count,
}))
.sort((a, b) => b.count - a.count)
const issues = rawIssues
.filter((i) => i.oceanIssue !== null)
.map((i) => ({
label: formatEnumLabel(i.oceanIssue!),
count: i._count,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 5)
const maxCategoryCount = Math.max(...categories.map((c) => c.count), 1)
const maxIssueCount = Math.max(...issues.map((i) => i.count), 1)
return (
<Card className="h-full">
<CardHeader className="pb-3">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/10">
<Layers className="h-4 w-4 text-violet-500" />
</div>
<CardTitle className="text-base">Categories</CardTitle>
</div>
</CardHeader>
<CardContent>
{categories.length === 0 && issues.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Layers className="h-8 w-8 text-muted-foreground/30" />
<p className="mt-2 text-xs text-muted-foreground">
No category data
</p>
</div>
) : (
<div className="space-y-5">
{categories.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
Competition Type
</p>
{categories.map((cat) => (
<div key={cat.label} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="truncate mr-2">{cat.label}</span>
<span className="font-bold tabular-nums">{cat.count}</span>
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-brand-blue to-brand-teal"
initial={{ width: 0 }}
animate={{ width: `${(cat.count / maxCategoryCount) * 100}%` }}
transition={{ duration: 0.6, delay: 0.3, ease: 'easeOut' }}
/>
</div>
</div>
))}
</div>
)}
{issues.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
Top Issues
</p>
{issues.map((issue) => (
<div key={issue.label} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="truncate mr-2">{issue.label}</span>
<span className="font-bold tabular-nums">{issue.count}</span>
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-teal-light"
initial={{ width: 0 }}
animate={{ width: `${(issue.count / maxIssueCount) * 100}%` }}
transition={{ duration: 0.6, delay: 0.4, ease: 'easeOut' }}
/>
</div>
</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,110 @@
'use client'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { motion } from 'motion/react'
import { Workflow, ArrowRight } from 'lucide-react'
import {
PipelineRoundNode,
type PipelineRound,
} from '@/components/dashboard/pipeline-round-node'
function Connector({
prevStatus,
index,
}: {
prevStatus: string
index: number
}) {
const isCompleted =
prevStatus === 'ROUND_CLOSED' || prevStatus === 'ROUND_ARCHIVED'
return (
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.25, delay: 0.15 + index * 0.06 }}
className="flex items-center self-center origin-left"
>
<div
className={cn(
'h-0.5 w-6',
isCompleted ? 'bg-emerald-300' : 'bg-slate-200'
)}
/>
</motion.div>
)
}
export function CompetitionPipeline({
rounds,
}: {
rounds: PipelineRound[]
}) {
if (rounds.length === 0) {
return (
<Card className="border-dashed">
<CardHeader className="pb-3">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-500/10">
<Workflow className="h-4 w-4 text-slate-500" />
</div>
<CardTitle className="text-base">Competition Pipeline</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
<Workflow className="h-7 w-7 text-muted-foreground/40" />
</div>
<p className="mt-3 text-sm font-medium text-muted-foreground">
No rounds configured yet
</p>
<p className="mt-1 text-xs text-muted-foreground">
Create rounds to visualize the competition pipeline.
</p>
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-teal/10">
<Workflow className="h-4 w-4 text-brand-teal" />
</div>
<CardTitle className="text-base">Competition Pipeline</CardTitle>
</div>
<Link
href="/admin/rounds"
className="flex items-center gap-1 text-xs font-semibold uppercase tracking-wider text-brand-teal hover:text-brand-teal-light transition-colors"
>
All rounds <ArrowRight className="h-3 w-3" />
</Link>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto pb-2">
<div className="flex items-start gap-0 min-w-max">
{rounds.map((round, index) => (
<div key={round.id} className="flex items-start">
<PipelineRoundNode round={round} index={index} />
{index < rounds.length - 1 && (
<Connector
prevStatus={round.status}
index={index}
/>
)}
</div>
))}
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
export function DashboardSkeleton() {
return (
<>
{/* Header skeleton */}
<Skeleton className="h-32 w-full rounded-2xl" />
{/* Stats row skeleton */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
{[...Array(5)].map((_, i) => (
<Card key={i} className="border-l-4 border-l-muted">
<CardContent className="p-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="mt-2 h-8 w-12" />
<Skeleton className="mt-1 h-3 w-20" />
</CardContent>
</Card>
))}
</div>
{/* Two-column content skeleton */}
<div className="grid gap-6 lg:grid-cols-12">
<div className="space-y-6 lg:col-span-8">
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-24 w-full rounded-xl" />
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
<Skeleton className="h-3 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-6 lg:col-span-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader><Skeleton className="h-5 w-32" /></CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(3)].map((_, j) => (
<Skeleton key={j} className="h-10 w-full" />
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
{/* Bottom skeleton */}
<div className="grid gap-6 lg:grid-cols-12">
<Skeleton className="h-[400px] w-full rounded-lg lg:col-span-8" />
<Skeleton className="h-[400px] w-full rounded-lg lg:col-span-4" />
</div>
</>
)
}

View File

@@ -0,0 +1,216 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { cn } from '@/lib/utils'
import { motion } from 'motion/react'
import {
Upload,
Filter,
ClipboardCheck,
FileUp,
GraduationCap,
Radio,
Scale,
} from 'lucide-react'
type PipelineRound = {
id: string
name: string
slug: string
roundType:
| 'INTAKE'
| 'FILTERING'
| 'EVALUATION'
| 'SUBMISSION'
| 'MENTORING'
| 'LIVE_FINAL'
| 'DELIBERATION'
status:
| 'ROUND_DRAFT'
| 'ROUND_ACTIVE'
| 'ROUND_CLOSED'
| 'ROUND_ARCHIVED'
sortOrder: number
windowOpenAt: Date | null
windowCloseAt: Date | null
projectStates: {
PENDING: number
IN_PROGRESS: number
PASSED: number
REJECTED: number
COMPLETED: number
WITHDRAWN: number
total: number
}
assignmentCount: number
evalSubmitted: number
evalDraft: number
evalTotal: number
filteringPassed: number
filteringRejected: number
filteringFlagged: number
filteringTotal: number
liveSessionStatus: string | null
deliberationCount: number
}
const roundTypeConfig: Record<
string,
{ icon: typeof Upload; iconColor: string; iconBg: string }
> = {
INTAKE: { icon: Upload, iconColor: 'text-sky-600', iconBg: 'bg-sky-100' },
FILTERING: {
icon: Filter,
iconColor: 'text-amber-600',
iconBg: 'bg-amber-100',
},
EVALUATION: {
icon: ClipboardCheck,
iconColor: 'text-violet-600',
iconBg: 'bg-violet-100',
},
SUBMISSION: {
icon: FileUp,
iconColor: 'text-blue-600',
iconBg: 'bg-blue-100',
},
MENTORING: {
icon: GraduationCap,
iconColor: 'text-teal-600',
iconBg: 'bg-teal-100',
},
LIVE_FINAL: {
icon: Radio,
iconColor: 'text-red-600',
iconBg: 'bg-red-100',
},
DELIBERATION: {
icon: Scale,
iconColor: 'text-indigo-600',
iconBg: 'bg-indigo-100',
},
}
const statusStyles: Record<
string,
{ container: string; label: string }
> = {
ROUND_DRAFT: {
container:
'bg-slate-50 border-slate-200 text-slate-400 border-dashed',
label: 'Draft',
},
ROUND_ACTIVE: {
container:
'bg-blue-50 border-blue-300 text-blue-700 ring-2 ring-blue-400/30 shadow-lg shadow-blue-500/10',
label: 'Active',
},
ROUND_CLOSED: {
container: 'bg-emerald-50 border-emerald-200 text-emerald-600',
label: 'Closed',
},
ROUND_ARCHIVED: {
container: 'bg-slate-50/50 border-slate-100 text-slate-300',
label: 'Archived',
},
}
function getMetric(round: PipelineRound): string {
const { roundType, projectStates, filteringTotal, filteringPassed, evalTotal, evalSubmitted, assignmentCount, liveSessionStatus, deliberationCount } = round
switch (roundType) {
case 'INTAKE':
return `${projectStates.total} submitted`
case 'FILTERING':
return filteringTotal > 0
? `${filteringPassed}/${filteringTotal} passed`
: `${projectStates.total} to filter`
case 'EVALUATION':
return evalTotal > 0
? `${evalSubmitted}/${evalTotal} evaluated`
: `${assignmentCount} assignments`
case 'SUBMISSION':
return `${projectStates.COMPLETED} submitted`
case 'MENTORING':
return '0 mentored'
case 'LIVE_FINAL':
return liveSessionStatus || `${projectStates.total} finalists`
case 'DELIBERATION':
return deliberationCount > 0
? `${deliberationCount} sessions`
: 'Not started'
default:
return `${projectStates.total} projects`
}
}
export function PipelineRoundNode({
round,
index,
}: {
round: PipelineRound
index: number
}) {
const typeConfig = roundTypeConfig[round.roundType] ?? roundTypeConfig.INTAKE
const Icon = typeConfig.icon
const status = statusStyles[round.status] ?? statusStyles.ROUND_DRAFT
const isActive = round.status === 'ROUND_ACTIVE'
const metric = getMetric(round)
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 + index * 0.06 }}
>
<Link
href={`/admin/rounds/${round.id}` as Route}
className="group block"
>
<div
className={cn(
'relative flex flex-col items-center rounded-xl border-2 p-3 transition-all hover:-translate-y-0.5 hover:shadow-md',
isActive ? 'w-44' : 'w-36',
status.container
)}
>
{/* Active ping indicator */}
{isActive && (
<span className="absolute -right-1 -top-1 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-blue-500" />
</span>
)}
{/* Icon */}
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-lg',
typeConfig.iconBg
)}
>
<Icon className={cn('h-4 w-4', typeConfig.iconColor)} />
</div>
{/* Name */}
<p className="mt-2 text-center text-xs font-semibold leading-tight line-clamp-2 group-hover:text-foreground transition-colors">
{round.name}
</p>
{/* Status label */}
<span className="mt-1.5 text-[10px] font-medium uppercase tracking-wider opacity-70">
{status.label}
</span>
{/* Metric */}
<p className="mt-1 text-[11px] font-medium tabular-nums opacity-80">
{metric}
</p>
</div>
</Link>
</motion.div>
)
}
export type { PipelineRound }

View File

@@ -0,0 +1,114 @@
'use client'
import Link from 'next/link'
import { motion } from 'motion/react'
import { ClipboardList, ArrowRight } from 'lucide-react'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { StatusBadge } from '@/components/shared/status-badge'
import { ProjectLogo } from '@/components/shared/project-logo'
import { getCountryName } from '@/lib/countries'
import { formatDateOnly, truncate } from '@/lib/utils'
type ProjectListCompactProps = {
projects: Array<{
id: string
title: string
teamName: string | null
country: string | null
competitionCategory: string | null
oceanIssue: string | null
logoKey: string | null
createdAt: Date
submittedAt: Date | null
status: string
}>
}
export function ProjectListCompact({ projects }: ProjectListCompactProps) {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10">
<ClipboardList className="h-4 w-4 text-brand-blue" />
</div>
<div>
<CardTitle className="text-base">Recent Projects</CardTitle>
<CardDescription className="text-xs">Latest submissions</CardDescription>
</div>
</div>
<Link
href="/admin/projects"
className="flex items-center gap-1 text-xs font-semibold uppercase tracking-wider text-brand-teal hover:text-brand-teal-light transition-colors"
>
All projects <ArrowRight className="h-3 w-3" />
</Link>
</div>
</CardHeader>
<CardContent>
{projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
<ClipboardList className="h-7 w-7 text-muted-foreground/40" />
</div>
<p className="mt-3 text-sm font-medium text-muted-foreground">
No projects submitted yet
</p>
</div>
) : (
<div className="divide-y">
{projects.map((project, idx) => (
<motion.div
key={project.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.25, delay: 0.15 + idx * 0.04 }}
>
<Link
href={`/admin/projects/${project.id}`}
className="block"
>
<div className="flex items-center gap-3 py-3 px-1 transition-colors hover:bg-muted/40 rounded-lg group">
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
{truncate(project.title, 50)}
</p>
<StatusBadge
status={project.status ?? 'SUBMITTED'}
size="sm"
className="shrink-0"
/>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{[
project.teamName,
project.country ? getCountryName(project.country) : null,
formatDateOnly(project.submittedAt || project.createdAt),
]
.filter(Boolean)
.join(' \u00b7 ')}
</p>
</div>
</div>
</Link>
</motion.div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,119 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
ClipboardList,
CheckCircle2,
FileEdit,
Users,
} from 'lucide-react'
type PipelineRound = {
id: string
name: string
roundType: string
status: string
assignmentCount: number
evalSubmitted: number
evalDraft: number
evalTotal: number
}
type RoundStatsEvaluationProps = {
round: PipelineRound
activeJurors: number
}
export function RoundStatsEvaluation({ round, activeJurors }: RoundStatsEvaluationProps) {
const { assignmentCount, evalSubmitted, evalDraft, evalTotal } = round
const completionPct = evalTotal > 0 ? ((evalSubmitted / evalTotal) * 100).toFixed(0) : '0'
return (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{round.name} Evaluation
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Assignments</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{assignmentCount}</p>
<p className="mt-0.5 text-xs text-brand-blue-light">
Jury-project pairs
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
<ClipboardList className="h-5 w-5 text-brand-blue" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Submitted</p>
<p className="mt-1 text-2xl font-bold tabular-nums">
{evalSubmitted}
<span className="text-sm font-normal text-muted-foreground">/{evalTotal}</span>
</p>
<p className="mt-0.5 text-xs text-emerald-600">
{completionPct}% complete
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">In Draft</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{evalDraft}</p>
<p className="mt-0.5 text-xs text-amber-600">
{evalDraft > 0 ? 'Not yet submitted' : 'No drafts'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
<FileEdit className="h-5 w-5 text-amber-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Active Jurors</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{activeJurors}</p>
<p className="mt-0.5 text-xs text-violet-600">
Evaluating this round
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-violet-500/10">
<Users className="h-5 w-5 text-violet-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
</div>
)
}

View File

@@ -0,0 +1,127 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
Filter,
CheckCircle2,
XCircle,
AlertTriangle,
} from 'lucide-react'
type PipelineRound = {
id: string
name: string
roundType: string
status: string
projectStates: {
PENDING: number
IN_PROGRESS: number
PASSED: number
REJECTED: number
COMPLETED: number
WITHDRAWN: number
total: number
}
filteringPassed: number
filteringRejected: number
filteringFlagged: number
filteringTotal: number
}
type RoundStatsFilteringProps = {
round: PipelineRound
}
export function RoundStatsFiltering({ round }: RoundStatsFilteringProps) {
const { filteringPassed, filteringRejected, filteringFlagged, projectStates } = round
return (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{round.name} Filtering
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Projects to Filter</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.total}</p>
<p className="mt-0.5 text-xs text-brand-blue-light">
In pipeline
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
<Filter className="h-5 w-5 text-brand-blue" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">AI Passed</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{filteringPassed}</p>
<p className="mt-0.5 text-xs text-emerald-600">
{projectStates.total > 0
? `${((filteringPassed / projectStates.total) * 100).toFixed(0)}% pass rate`
: 'No results yet'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-red-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">AI Rejected</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{filteringRejected}</p>
<p className="mt-0.5 text-xs text-red-600">
{projectStates.total > 0
? `${((filteringRejected / projectStates.total) * 100).toFixed(0)}% rejected`
: 'No results yet'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-500/10">
<XCircle className="h-5 w-5 text-red-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Flagged for Review</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{filteringFlagged}</p>
<p className="mt-0.5 text-xs text-amber-600">
{filteringFlagged > 0 ? 'Needs manual review' : 'None flagged'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
<AlertTriangle className="h-5 w-5 text-amber-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
</div>
)
}

View File

@@ -0,0 +1,121 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
ClipboardList,
Users,
CheckCircle2,
AlertTriangle,
} from 'lucide-react'
type RoundStatsGenericProps = {
projectCount: number
newProjectsThisWeek: number
totalJurors: number
activeJurors: number
totalAssignments: number
evaluationStats: Array<{ status: string; _count: number }>
actionsCount: number
}
export function RoundStatsGeneric({
projectCount,
newProjectsThisWeek,
totalJurors,
activeJurors,
totalAssignments,
evaluationStats,
actionsCount,
}: RoundStatsGenericProps) {
const submittedCount =
evaluationStats.find((e) => e.status === 'SUBMITTED')?._count ?? 0
const completionPct =
totalAssignments > 0 ? ((submittedCount / totalAssignments) * 100).toFixed(0) : '0'
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Projects</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{projectCount}</p>
<p className="mt-0.5 text-xs text-brand-blue-light">
{newProjectsThisWeek > 0 ? `+${newProjectsThisWeek} this week` : 'In edition'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
<ClipboardList className="h-5 w-5 text-brand-blue" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Jury</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{totalJurors}</p>
<p className="mt-0.5 text-xs text-violet-600">
{activeJurors} active
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-violet-500/10">
<Users className="h-5 w-5 text-violet-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Evaluations</p>
<p className="mt-1 text-2xl font-bold tabular-nums">
{submittedCount}
<span className="text-sm font-normal text-muted-foreground">/{totalAssignments}</span>
</p>
<p className="mt-0.5 text-xs text-emerald-600">
{completionPct}% complete
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className={`border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md ${actionsCount > 0 ? 'border-l-amber-500' : 'border-l-emerald-400'}`}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Actions Needed</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{actionsCount}</p>
<p className={`mt-0.5 text-xs ${actionsCount > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
{actionsCount > 0 ? 'Pending actions' : 'All clear'}
</p>
</div>
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${actionsCount > 0 ? 'bg-amber-500/10' : 'bg-emerald-400/10'}`}>
{actionsCount > 0
? <AlertTriangle className="h-5 w-5 text-amber-500" />
: <CheckCircle2 className="h-5 w-5 text-emerald-400" />
}
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
)
}

View File

@@ -0,0 +1,122 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
ClipboardList,
CheckCircle2,
Clock,
TrendingUp,
} from 'lucide-react'
type PipelineRound = {
id: string
name: string
roundType: string
status: string
projectStates: {
PENDING: number
IN_PROGRESS: number
PASSED: number
REJECTED: number
COMPLETED: number
WITHDRAWN: number
total: number
}
}
type RoundStatsIntakeProps = {
round: PipelineRound
newProjectsThisWeek: number
}
export function RoundStatsIntake({ round, newProjectsThisWeek }: RoundStatsIntakeProps) {
const { projectStates } = round
return (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{round.name} Intake
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Projects</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.total}</p>
<p className="mt-0.5 text-xs text-brand-blue-light">
In this round
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
<ClipboardList className="h-5 w-5 text-brand-blue" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Documents Complete</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.PASSED}</p>
<p className="mt-0.5 text-xs text-emerald-600">
{projectStates.total > 0
? `${((projectStates.PASSED / projectStates.total) * 100).toFixed(0)}% of total`
: 'No projects yet'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Pending Review</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.PENDING}</p>
<p className="mt-0.5 text-xs text-amber-600">
{projectStates.PENDING > 0 ? 'Awaiting review' : 'All reviewed'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
<Clock className="h-5 w-5 text-amber-500" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">New This Week</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{newProjectsThisWeek}</p>
<p className="mt-0.5 text-xs text-brand-teal">
{newProjectsThisWeek > 0 ? 'Recently submitted' : 'No new submissions'}
</p>
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-teal/10">
<TrendingUp className="h-5 w-5 text-brand-teal" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
</div>
)
}

View File

@@ -0,0 +1,105 @@
'use client'
import { RoundStatsIntake } from '@/components/dashboard/round-stats-intake'
import { RoundStatsFiltering } from '@/components/dashboard/round-stats-filtering'
import { RoundStatsEvaluation } from '@/components/dashboard/round-stats-evaluation'
import { RoundStatsGeneric } from '@/components/dashboard/round-stats-generic'
type PipelineRound = {
id: string
name: string
roundType: string
status: string
projectStates: {
PENDING: number
IN_PROGRESS: number
PASSED: number
REJECTED: number
COMPLETED: number
WITHDRAWN: number
total: number
}
assignmentCount: number
evalSubmitted: number
evalDraft: number
evalTotal: number
filteringPassed: number
filteringRejected: number
filteringFlagged: number
filteringTotal: number
liveSessionStatus: string | null
deliberationCount: number
windowOpenAt: Date | null
windowCloseAt: Date | null
sortOrder: number
slug: string
}
type RoundStatsProps = {
activeRound: PipelineRound | null
projectCount: number
newProjectsThisWeek: number
totalJurors: number
activeJurors: number
totalAssignments: number
evaluationStats: Array<{ status: string; _count: number }>
actionsCount: number
}
export function RoundStats({
activeRound,
projectCount,
newProjectsThisWeek,
totalJurors,
activeJurors,
totalAssignments,
evaluationStats,
actionsCount,
}: RoundStatsProps) {
if (!activeRound) {
return (
<RoundStatsGeneric
projectCount={projectCount}
newProjectsThisWeek={newProjectsThisWeek}
totalJurors={totalJurors}
activeJurors={activeJurors}
totalAssignments={totalAssignments}
evaluationStats={evaluationStats}
actionsCount={actionsCount}
/>
)
}
switch (activeRound.roundType) {
case 'INTAKE':
return (
<RoundStatsIntake
round={activeRound}
newProjectsThisWeek={newProjectsThisWeek}
/>
)
case 'FILTERING':
return (
<RoundStatsFiltering round={activeRound} />
)
case 'EVALUATION':
return (
<RoundStatsEvaluation
round={activeRound}
activeJurors={activeJurors}
/>
)
default:
return (
<RoundStatsGeneric
projectCount={projectCount}
newProjectsThisWeek={newProjectsThisWeek}
totalJurors={totalJurors}
activeJurors={activeJurors}
totalAssignments={totalAssignments}
evaluationStats={evaluationStats}
actionsCount={actionsCount}
/>
)
}
}

View File

@@ -0,0 +1,119 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import {
Zap,
AlertTriangle,
AlertCircle,
Info,
ChevronRight,
CheckCircle2,
} from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
export type DashboardAction = {
id: string
severity: 'critical' | 'warning' | 'info'
title: string
description: string
href: string
roundId?: string
roundType?: string
count?: number
}
type SmartActionsProps = {
actions: DashboardAction[]
}
const severityOrder: Record<DashboardAction['severity'], number> = {
critical: 0,
warning: 1,
info: 2,
}
const severityConfig = {
critical: {
icon: AlertTriangle,
iconClass: 'text-red-600',
bgClass: 'bg-red-50 dark:bg-red-950/30',
borderClass: 'border-l-red-500',
},
warning: {
icon: AlertCircle,
iconClass: 'text-amber-600',
bgClass: 'bg-amber-50 dark:bg-amber-950/30',
borderClass: 'border-l-amber-500',
},
info: {
icon: Info,
iconClass: 'text-blue-600',
bgClass: 'bg-blue-50 dark:bg-blue-950/30',
borderClass: 'border-l-blue-500',
},
}
export function SmartActions({ actions }: SmartActionsProps) {
const sorted = [...actions].sort(
(a, b) => severityOrder[a.severity] - severityOrder[b.severity]
)
return (
<Card>
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/40">
<Zap className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<CardTitle className="flex-1">Action Required</CardTitle>
{actions.length > 0 && (
<Badge variant="warning">{actions.length}</Badge>
)}
</CardHeader>
<CardContent>
{sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/40">
<CheckCircle2 className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
</div>
<p className="mt-3 text-sm font-medium text-muted-foreground">
All caught up!
</p>
</div>
) : (
<div className="space-y-2">
{sorted.map((action) => {
const config = severityConfig[action.severity]
const Icon = config.icon
return (
<Link
key={action.id}
href={action.href as Route}
className={cn(
'flex items-center gap-3 rounded-lg border-l-2 px-3 py-2.5 transition-colors hover:opacity-80',
config.bgClass,
config.borderClass
)}
>
<Icon className={cn('h-4 w-4 shrink-0', config.iconClass)} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium leading-tight">
{action.title}
</p>
<p className="text-xs text-muted-foreground">
{action.description}
</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</Link>
)
})}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,175 @@
import {
Plus,
FileEdit,
Trash2,
LogIn,
ArrowRight,
Send,
Users,
UserPlus,
Upload,
Eye,
} from 'lucide-react'
import { createElement } from 'react'
export function formatEntity(entityType: string | null): string {
if (!entityType) return 'record'
return entityType
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.toLowerCase()
}
export function formatAction(action: string, entityType: string | null): string {
const entity = formatEntity(entityType)
const actionMap: Record<string, string> = {
CREATE: `created a ${entity}`,
UPDATE: `updated a ${entity}`,
DELETE: `deleted a ${entity}`,
IMPORT: `imported ${entity}s`,
EXPORT: `exported ${entity} data`,
REORDER: `reordered ${entity}s`,
LOGIN: 'logged in',
LOGIN_SUCCESS: 'logged in',
LOGIN_FAILED: 'failed to log in',
PASSWORD_SET: 'set their password',
PASSWORD_CHANGED: 'changed their password',
REQUEST_PASSWORD_RESET: 'requested a password reset',
COMPLETE_ONBOARDING: 'completed onboarding',
DELETE_OWN_ACCOUNT: 'deleted their account',
EVALUATION_SUBMITTED: 'submitted an evaluation',
COI_DECLARED: 'declared a conflict of interest',
COI_REVIEWED: 'reviewed a COI declaration',
REMINDERS_TRIGGERED: 'triggered evaluation reminders',
DISCUSSION_COMMENT_ADDED: 'added a discussion comment',
DISCUSSION_CLOSED: 'closed a discussion',
ASSIGN: `assigned a ${entity}`,
BULK_CREATE: `bulk created ${entity}s`,
BULK_ASSIGN: 'bulk assigned users',
BULK_DELETE: `bulk deleted ${entity}s`,
BULK_UPDATE: `bulk updated ${entity}s`,
BULK_UPDATE_STATUS: 'bulk updated statuses',
APPLY_SUGGESTIONS: 'applied assignment suggestions',
ASSIGN_PROJECTS_TO_ROUND: 'assigned projects to round',
REMOVE_PROJECTS_FROM_ROUND: 'removed projects from round',
ADVANCE_PROJECTS: 'advanced projects to next round',
BULK_ASSIGN_TO_ROUND: 'bulk assigned to round',
REORDER_ROUNDS: 'reordered rounds',
STATUS_CHANGE: `changed ${entity} status`,
UPDATE_STATUS: `updated ${entity} status`,
ROLE_CHANGED: 'changed a user role',
INVITE: 'invited a user',
SEND_INVITATION: 'sent an invitation',
BULK_SEND_INVITATIONS: 'sent bulk invitations',
UPLOAD_FILE: 'uploaded a file',
DELETE_FILE: 'deleted a file',
REPLACE_FILE: 'replaced a file',
FILE_DOWNLOADED: 'downloaded a file',
EXECUTE_FILTERING: 'ran project filtering',
FINALIZE_FILTERING: 'finalized filtering results',
OVERRIDE: `overrode a ${entity} result`,
BULK_OVERRIDE: 'bulk overrode filtering results',
REINSTATE: 'reinstated a project',
BULK_REINSTATE: 'bulk reinstated projects',
AI_TAG: 'ran AI tagging',
START_AI_TAG_JOB: 'started AI tagging job',
EVALUATION_SUMMARY: 'generated an AI summary',
AWARD_ELIGIBILITY: 'ran award eligibility check',
PROJECT_TAGGING: 'ran project tagging',
FILTERING: 'ran AI filtering',
MENTOR_MATCHING: 'ran mentor matching',
ADD_TAG: 'added a tag',
REMOVE_TAG: 'removed a tag',
BULK_CREATE_TAGS: 'bulk created tags',
MENTOR_ASSIGN: 'assigned a mentor',
MENTOR_UNASSIGN: 'unassigned a mentor',
MENTOR_AUTO_ASSIGN: 'auto-assigned mentors',
MENTOR_BULK_ASSIGN: 'bulk assigned mentors',
CREATE_MENTOR_NOTE: 'created a mentor note',
COMPLETE_MILESTONE: 'completed a milestone',
SEND_MESSAGE: 'sent a message',
CREATE_MESSAGE_TEMPLATE: 'created a message template',
UPDATE_MESSAGE_TEMPLATE: 'updated a message template',
DELETE_MESSAGE_TEMPLATE: 'deleted a message template',
CREATE_WEBHOOK: 'created a webhook',
UPDATE_WEBHOOK: 'updated a webhook',
DELETE_WEBHOOK: 'deleted a webhook',
TEST_WEBHOOK: 'tested a webhook',
REGENERATE_WEBHOOK_SECRET: 'regenerated a webhook secret',
UPDATE_SETTING: 'updated a setting',
UPDATE_SETTINGS_BATCH: 'updated settings',
UPDATE_NOTIFICATION_PREFERENCES: 'updated notification preferences',
UPDATE_DIGEST_SETTINGS: 'updated digest settings',
UPDATE_ANALYTICS_SETTINGS: 'updated analytics settings',
UPDATE_AUDIT_SETTINGS: 'updated audit settings',
UPDATE_LOCALIZATION_SETTINGS: 'updated localization settings',
UPDATE_RETENTION_CONFIG: 'updated retention config',
START_VOTING: 'started live voting',
END_SESSION: 'ended a live voting session',
UPDATE_SESSION_CONFIG: 'updated session config',
CREATE_ROUND_TEMPLATE: 'created a round template',
CREATE_ROUND_TEMPLATE_FROM_ROUND: 'saved round as template',
UPDATE_ROUND_TEMPLATE: 'updated a round template',
DELETE_ROUND_TEMPLATE: 'deleted a round template',
UPDATE_EVALUATION_FORM: 'updated the evaluation form',
GRANT_GRACE_PERIOD: 'granted a grace period',
UPDATE_GRACE_PERIOD: 'updated a grace period',
REVOKE_GRACE_PERIOD: 'revoked a grace period',
BULK_GRANT_GRACE_PERIOD: 'bulk granted grace periods',
SET_AWARD_WINNER: 'set an award winner',
REPORT_GENERATED: 'generated a report',
DRAFT_SUBMITTED: 'submitted a draft application',
SUBMIT: `submitted a ${entity}`,
}
if (actionMap[action]) return actionMap[action]
return action.toLowerCase().replace(/_/g, ' ')
}
const iconClass = 'h-3 w-3'
export function getActionIcon(action: string) {
switch (action) {
case 'CREATE':
case 'BULK_CREATE':
return createElement(Plus, { className: iconClass })
case 'UPDATE':
case 'UPDATE_STATUS':
case 'BULK_UPDATE':
case 'BULK_UPDATE_STATUS':
case 'STATUS_CHANGE':
case 'ROLE_CHANGED':
return createElement(FileEdit, { className: iconClass })
case 'DELETE':
case 'BULK_DELETE':
return createElement(Trash2, { className: iconClass })
case 'LOGIN':
case 'LOGIN_SUCCESS':
case 'LOGIN_FAILED':
case 'PASSWORD_SET':
case 'PASSWORD_CHANGED':
case 'COMPLETE_ONBOARDING':
return createElement(LogIn, { className: iconClass })
case 'EXPORT':
case 'REPORT_GENERATED':
return createElement(ArrowRight, { className: iconClass })
case 'SUBMIT':
case 'EVALUATION_SUBMITTED':
case 'DRAFT_SUBMITTED':
return createElement(Send, { className: iconClass })
case 'ASSIGN':
case 'BULK_ASSIGN':
case 'APPLY_SUGGESTIONS':
case 'ASSIGN_PROJECTS_TO_ROUND':
case 'MENTOR_ASSIGN':
case 'MENTOR_BULK_ASSIGN':
return createElement(Users, { className: iconClass })
case 'INVITE':
case 'SEND_INVITATION':
case 'BULK_SEND_INVITATIONS':
return createElement(UserPlus, { className: iconClass })
case 'IMPORT':
return createElement(Upload, { className: iconClass })
default:
return createElement(Eye, { className: iconClass })
}
}

29
src/instrumentation.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Next.js Instrumentation — runs once on server startup.
* https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
*/
export async function onRequestInit() {
// no-op — required export for instrumentation file
}
export async function register() {
// Only run on the Node.js server runtime (not edge, not build)
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Retroactive document analysis: analyze all files that haven't been analyzed yet.
// Runs in background on startup, non-blocking.
import('./server/services/document-analyzer')
.then(({ analyzeAllUnanalyzed }) => {
console.log('[Startup] Starting retroactive document analysis...')
analyzeAllUnanalyzed()
.then((result) => {
console.log(
`[Startup] Document analysis complete: ${result.analyzed} analyzed, ${result.skipped} skipped, ${result.failed} failed out of ${result.total} total`
)
})
.catch((err) => {
console.warn('[Startup] Document analysis failed:', err)
})
})
.catch(() => {})
}
}

View File

@@ -1,11 +1,72 @@
import { z } from 'zod' import { z } from 'zod'
import { router, adminProcedure } from '../trpc' import { router, adminProcedure } from '../trpc'
import type { RoundType, RoundStatus, ProjectRoundStateValue } from '@prisma/client'
// ─── Types ──────────────────────────────────────────────────────────────────
type ProjectStateCounts = {
PENDING: number
IN_PROGRESS: number
PASSED: number
REJECTED: number
COMPLETED: number
WITHDRAWN: number
total: number
}
export type PipelineRound = {
id: string
name: string
slug: string
roundType: RoundType
status: RoundStatus
sortOrder: number
windowOpenAt: Date | null
windowCloseAt: Date | null
projectStates: ProjectStateCounts
assignmentCount: number
evalSubmitted: number
evalDraft: number
evalTotal: number
filteringPassed: number
filteringRejected: number
filteringFlagged: number
filteringTotal: number
liveSessionStatus: string | null
deliberationCount: number
}
export type DashboardAction = {
id: string
severity: 'critical' | 'warning' | 'info'
title: string
description: string
href: string
roundId?: string
roundType?: RoundType
count?: number
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function emptyStateCounts(): ProjectStateCounts {
return { PENDING: 0, IN_PROGRESS: 0, PASSED: 0, REJECTED: 0, COMPLETED: 0, WITHDRAWN: 0, total: 0 }
}
function daysUntil(date: Date): number {
return Math.ceil((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
}
function formatRoundType(rt: RoundType): string {
return rt.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
}
// ─── Router ──────────────────────────────────────────────────────────────────
export const dashboardRouter = router({ export const dashboardRouter = router({
/** /**
* Get all dashboard stats in a single query batch. * Get all dashboard stats in a single query batch.
* Replaces the 16 parallel Prisma queries that were previously * Returns pipeline rounds, smart actions, and supporting data.
* run during SSR, which blocked the event loop and caused 503s.
*/ */
getStats: adminProcedure getStats: adminProcedure
.input(z.object({ editionId: z.string() })) .input(z.object({ editionId: z.string() }))
@@ -21,70 +82,47 @@ export const dashboardRouter = router({
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
// ── All queries in parallel ──────────────────────────────────────
const [ const [
activeRoundCount, // Pipeline rounds (all, ordered by sortOrder)
totalRoundCount, allRounds,
// Per-round project state breakdown
stateBreakdown,
// Per-round eval data (assignments with eval status)
roundEvalData,
// Per-round filtering results
filteringStats,
// Live session statuses
liveSessions,
// Deliberation session counts
deliberationCounts,
// Summary counts
projectCount, projectCount,
newProjectsThisWeek, newProjectsThisWeek,
totalJurors, totalJurors,
activeJurors, activeJurors,
evaluationStats, evaluationStats,
totalAssignments, totalAssignments,
recentRounds, // Lists
latestProjects, latestProjects,
categoryBreakdown, categoryBreakdown,
oceanIssueBreakdown, oceanIssueBreakdown,
recentActivity, recentActivity,
// Action signals
pendingCOIs, pendingCOIs,
draftRounds,
unassignedProjects,
] = await Promise.all([ ] = await Promise.all([
ctx.prisma.round.count({ // 1. All pipeline rounds
where: { competition: { programId: editionId }, status: 'ROUND_ACTIVE' },
}),
ctx.prisma.round.count({
where: { competition: { programId: editionId } },
}),
ctx.prisma.project.count({
where: { programId: editionId },
}),
ctx.prisma.project.count({
where: {
programId: editionId,
createdAt: { gte: sevenDaysAgo },
},
}),
ctx.prisma.user.count({
where: {
role: 'JURY_MEMBER',
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
assignments: { some: { round: { competition: { programId: editionId } } } },
},
}),
ctx.prisma.user.count({
where: {
role: 'JURY_MEMBER',
status: 'ACTIVE',
assignments: { some: { round: { competition: { programId: editionId } } } },
},
}),
ctx.prisma.evaluation.groupBy({
by: ['status'],
where: { assignment: { round: { competition: { programId: editionId } } } },
_count: true,
}),
ctx.prisma.assignment.count({
where: { round: { competition: { programId: editionId } } },
}),
ctx.prisma.round.findMany({ ctx.prisma.round.findMany({
where: { competition: { programId: editionId } }, where: { competition: { programId: editionId } },
orderBy: { createdAt: 'desc' }, orderBy: { sortOrder: 'asc' },
take: 5,
select: { select: {
id: true, id: true,
name: true, name: true,
status: true, slug: true,
roundType: true, roundType: true,
status: true,
sortOrder: true,
windowOpenAt: true, windowOpenAt: true,
windowCloseAt: true, windowCloseAt: true,
_count: { _count: {
@@ -93,13 +131,86 @@ export const dashboardRouter = router({
assignments: true, assignments: true,
}, },
}, },
assignments: {
select: {
evaluation: { select: { status: true } },
},
},
}, },
}), }),
// 2. Per-round project state counts
ctx.prisma.projectRoundState.groupBy({
by: ['roundId', 'state'],
where: { round: { competition: { programId: editionId } } },
_count: true,
}),
// 3. Assignments with eval status (for per-round eval aggregation)
ctx.prisma.assignment.findMany({
where: { round: { competition: { programId: editionId } } },
select: {
roundId: true,
evaluation: { select: { status: true } },
},
}),
// 4. Filtering results per round
ctx.prisma.filteringResult.groupBy({
by: ['roundId', 'outcome'],
where: { round: { competition: { programId: editionId } } },
_count: true,
}),
// 5. Live session statuses
ctx.prisma.liveVotingSession.findMany({
where: { round: { competition: { programId: editionId } } },
select: { roundId: true, status: true },
}),
// 6. Deliberation session counts
ctx.prisma.deliberationSession.groupBy({
by: ['roundId'],
where: { competition: { programId: editionId } },
_count: true,
}),
// 7. Project count
ctx.prisma.project.count({
where: { programId: editionId },
}),
// 8. New projects this week
ctx.prisma.project.count({
where: { programId: editionId, createdAt: { gte: sevenDaysAgo } },
}),
// 9. Total jurors
ctx.prisma.user.count({
where: {
role: 'JURY_MEMBER',
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
assignments: { some: { round: { competition: { programId: editionId } } } },
},
}),
// 10. Active jurors
ctx.prisma.user.count({
where: {
role: 'JURY_MEMBER',
status: 'ACTIVE',
assignments: { some: { round: { competition: { programId: editionId } } } },
},
}),
// 11. Global evaluation stats
ctx.prisma.evaluation.groupBy({
by: ['status'],
where: { assignment: { round: { competition: { programId: editionId } } } },
_count: true,
}),
// 12. Total assignments
ctx.prisma.assignment.count({
where: { round: { competition: { programId: editionId } } },
}),
// 13. Latest projects
ctx.prisma.project.findMany({ ctx.prisma.project.findMany({
where: { programId: editionId }, where: { programId: editionId },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
@@ -117,20 +228,24 @@ export const dashboardRouter = router({
status: true, status: true,
}, },
}), }),
// 14. Category breakdown
ctx.prisma.project.groupBy({ ctx.prisma.project.groupBy({
by: ['competitionCategory'], by: ['competitionCategory'],
where: { programId: editionId }, where: { programId: editionId },
_count: true, _count: true,
}), }),
// 15. Ocean issue breakdown
ctx.prisma.project.groupBy({ ctx.prisma.project.groupBy({
by: ['oceanIssue'], by: ['oceanIssue'],
where: { programId: editionId }, where: { programId: editionId },
_count: true, _count: true,
}), }),
// 16. Recent activity
ctx.prisma.auditLog.findMany({ ctx.prisma.auditLog.findMany({
where: { where: { timestamp: { gte: sevenDaysAgo } },
timestamp: { gte: sevenDaysAgo },
},
orderBy: { timestamp: 'desc' }, orderBy: { timestamp: 'desc' },
take: 8, take: 8,
select: { select: {
@@ -141,6 +256,8 @@ export const dashboardRouter = router({
user: { select: { name: true } }, user: { select: { name: true } },
}, },
}), }),
// 17. Pending COIs
ctx.prisma.conflictOfInterest.count({ ctx.prisma.conflictOfInterest.count({
where: { where: {
hasConflict: true, hasConflict: true,
@@ -148,40 +265,204 @@ export const dashboardRouter = router({
assignment: { round: { competition: { programId: editionId } } }, assignment: { round: { competition: { programId: editionId } } },
}, },
}), }),
ctx.prisma.round.count({
where: { competition: { programId: editionId }, status: 'ROUND_DRAFT' },
}),
ctx.prisma.project.count({
where: {
programId: editionId,
projectRoundStates: {
some: {
round: { status: 'ROUND_ACTIVE' },
},
},
assignments: { none: {} },
},
}),
]) ])
// ── Assemble pipeline rounds ────────────────────────────────────
// Build state counts map: roundId -> ProjectStateCounts
const stateMap = new Map<string, ProjectStateCounts>()
for (const row of stateBreakdown) {
if (!stateMap.has(row.roundId)) stateMap.set(row.roundId, emptyStateCounts())
const counts = stateMap.get(row.roundId)!
const state = row.state as ProjectRoundStateValue
if (state in counts) {
counts[state as keyof Omit<ProjectStateCounts, 'total'>] = row._count
counts.total += row._count
}
}
// Build eval map: roundId -> { submitted, draft, total }
const evalMap = new Map<string, { submitted: number; draft: number; total: number }>()
for (const a of roundEvalData) {
if (!evalMap.has(a.roundId)) evalMap.set(a.roundId, { submitted: 0, draft: 0, total: 0 })
const entry = evalMap.get(a.roundId)!
entry.total++
if (a.evaluation?.status === 'SUBMITTED') entry.submitted++
else if (a.evaluation?.status === 'DRAFT') entry.draft++
}
// Build filtering map: roundId -> { passed, rejected, flagged, total }
const filterMap = new Map<string, { passed: number; rejected: number; flagged: number; total: number }>()
for (const row of filteringStats) {
if (!filterMap.has(row.roundId)) filterMap.set(row.roundId, { passed: 0, rejected: 0, flagged: 0, total: 0 })
const entry = filterMap.get(row.roundId)!
entry.total += row._count
if (row.outcome === 'PASSED') entry.passed = row._count
else if (row.outcome === 'FILTERED_OUT') entry.rejected = row._count
else if (row.outcome === 'FLAGGED') entry.flagged = row._count
}
// Build live session map: roundId -> status
const liveMap = new Map<string, string>()
for (const s of liveSessions) {
if (s.roundId) liveMap.set(s.roundId, s.status)
}
// Build deliberation map: roundId -> count
const delibMap = new Map<string, number>()
for (const row of deliberationCounts) {
delibMap.set(row.roundId, row._count)
}
// Assemble pipeline rounds
const pipelineRounds: PipelineRound[] = allRounds.map((round) => {
const states = stateMap.get(round.id) ?? emptyStateCounts()
const evals = evalMap.get(round.id) ?? { submitted: 0, draft: 0, total: 0 }
const filters = filterMap.get(round.id) ?? { passed: 0, rejected: 0, flagged: 0, total: 0 }
return {
id: round.id,
name: round.name,
slug: round.slug,
roundType: round.roundType,
status: round.status,
sortOrder: round.sortOrder,
windowOpenAt: round.windowOpenAt,
windowCloseAt: round.windowCloseAt,
projectStates: states,
assignmentCount: round._count.assignments,
evalSubmitted: evals.submitted,
evalDraft: evals.draft,
evalTotal: evals.total,
filteringPassed: filters.passed,
filteringRejected: filters.rejected,
filteringFlagged: filters.flagged,
filteringTotal: filters.total,
liveSessionStatus: liveMap.get(round.id) ?? null,
deliberationCount: delibMap.get(round.id) ?? 0,
}
})
// ── Determine active round ──────────────────────────────────────
const activeRound = pipelineRounds.find((r) => r.status === 'ROUND_ACTIVE') ?? null
const activeRoundId = activeRound?.id ?? null
// ── Compute smart actions ───────────────────────────────────────
const nextActions: DashboardAction[] = []
const activeRounds = pipelineRounds.filter((r) => r.status === 'ROUND_ACTIVE')
const lastActiveSortOrder = Math.max(...activeRounds.map((r) => r.sortOrder), -1)
// 1. Next draft round (only the first one after the last active)
const nextDraft = pipelineRounds.find(
(r) => r.status === 'ROUND_DRAFT' && r.sortOrder > lastActiveSortOrder
)
if (nextDraft) {
nextActions.push({
id: `draft-${nextDraft.id}`,
severity: 'info',
title: `Configure "${nextDraft.name}"`,
description: `Next round (${formatRoundType(nextDraft.roundType)}) is in draft`,
href: `/admin/rounds/${nextDraft.id}`,
roundId: nextDraft.id,
roundType: nextDraft.roundType,
})
}
// 2. Per-active-round actions
for (const round of activeRounds) {
// Evaluation rounds: flag unassigned projects
if (round.roundType === 'EVALUATION' && round.projectStates.total > 0 && round.assignmentCount === 0) {
nextActions.push({
id: `unassigned-${round.id}`,
severity: 'warning',
title: `${round.projectStates.total} unassigned projects`,
description: `"${round.name}" has projects without jury assignments`,
href: `/admin/rounds/${round.id}`,
roundId: round.id,
roundType: round.roundType,
count: round.projectStates.total,
})
}
// Filtering rounds: flag if filtering not started
if (round.roundType === 'FILTERING' && round.filteringTotal === 0 && round.projectStates.total > 0) {
nextActions.push({
id: `filtering-${round.id}`,
severity: 'warning',
title: 'Filtering not started',
description: `"${round.name}" has ${round.projectStates.total} projects awaiting filtering`,
href: `/admin/rounds/${round.id}`,
roundId: round.id,
roundType: round.roundType,
})
}
// Deadline warnings
if (round.windowCloseAt) {
const days = daysUntil(round.windowCloseAt)
if (days > 0 && days <= 3) {
nextActions.push({
id: `deadline-${round.id}`,
severity: 'critical',
title: `${days}d until "${round.name}" closes`,
description: `Window closes ${round.windowCloseAt.toLocaleDateString()}`,
href: `/admin/rounds/${round.id}`,
roundId: round.id,
roundType: round.roundType,
})
} else if (days > 3 && days <= 7) {
nextActions.push({
id: `deadline-${round.id}`,
severity: 'warning',
title: `${days}d until "${round.name}" closes`,
description: `Window closes ${round.windowCloseAt.toLocaleDateString()}`,
href: `/admin/rounds/${round.id}`,
roundId: round.id,
roundType: round.roundType,
})
}
}
}
// 3. Pending COIs
if (pendingCOIs > 0) {
nextActions.push({
id: 'pending-cois',
severity: 'warning',
title: `${pendingCOIs} COI declarations pending`,
description: 'Jury members have declared conflicts that need admin review',
href: '/admin/rounds',
count: pendingCOIs,
})
}
// Sort by severity
const severityOrder = { critical: 0, warning: 1, info: 2 }
nextActions.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity])
// ── Return ──────────────────────────────────────────────────────
return { return {
edition, edition,
activeRoundCount, // Pipeline
totalRoundCount, pipelineRounds,
activeRoundId,
// Smart actions
nextActions,
// Summary counts
projectCount, projectCount,
newProjectsThisWeek, newProjectsThisWeek,
totalJurors, totalJurors,
activeJurors, activeJurors,
evaluationStats, evaluationStats,
totalAssignments, totalAssignments,
recentRounds, pendingCOIs,
// Lists
latestProjects, latestProjects,
categoryBreakdown, categoryBreakdown,
oceanIssueBreakdown, oceanIssueBreakdown,
recentActivity, recentActivity,
pendingCOIs,
draftRounds,
unassignedProjects,
} }
}), }),
}) })