All checks were successful
Build and Push Docker Image / build (push) Successful in 9m0s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
299 lines
9.8 KiB
TypeScript
299 lines
9.8 KiB
TypeScript
'use client'
|
|
|
|
import Link from 'next/link'
|
|
import type { Route } from 'next'
|
|
import { trpc } from '@/lib/trpc/client'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
CircleDot,
|
|
AlertTriangle,
|
|
Upload,
|
|
UserPlus,
|
|
Settings,
|
|
ClipboardCheck,
|
|
Users,
|
|
Send,
|
|
FileDown,
|
|
Calendar,
|
|
Eye,
|
|
Presentation,
|
|
Vote,
|
|
Play,
|
|
Lock,
|
|
} from 'lucide-react'
|
|
import { GeographicSummaryCard } from '@/components/charts'
|
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
|
import { motion } from 'motion/react'
|
|
|
|
import { CompetitionPipeline } from '@/components/dashboard/competition-pipeline'
|
|
import { RoundStats } from '@/components/dashboard/round-stats'
|
|
import { ActiveRoundPanel } from '@/components/dashboard/active-round-panel'
|
|
import { SmartActions } from '@/components/dashboard/smart-actions'
|
|
import { ProjectListCompact } from '@/components/dashboard/project-list-compact'
|
|
import { ActivityFeed } from '@/components/dashboard/activity-feed'
|
|
import { CategoryBreakdown } from '@/components/dashboard/category-breakdown'
|
|
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton'
|
|
import { RecentEvaluations } from '@/components/dashboard/recent-evaluations'
|
|
|
|
type DashboardContentProps = {
|
|
editionId: string
|
|
sessionName: string
|
|
}
|
|
|
|
type QuickAction = {
|
|
label: string
|
|
href: string
|
|
icon: React.ElementType
|
|
}
|
|
|
|
function getContextualActions(
|
|
activeRound: { id: string; roundType: string } | null
|
|
): QuickAction[] {
|
|
if (!activeRound) {
|
|
return [
|
|
{ label: 'Rounds', href: '/admin/rounds', icon: CircleDot },
|
|
{ label: 'Import', href: '/admin/projects/new', icon: Upload },
|
|
{ label: 'Invite', href: '/admin/members', icon: UserPlus },
|
|
]
|
|
}
|
|
|
|
const roundHref = `/admin/rounds/${activeRound.id}`
|
|
|
|
switch (activeRound.roundType) {
|
|
case 'INTAKE':
|
|
return [
|
|
{ label: 'Import Projects', href: '/admin/projects/new', icon: Upload },
|
|
{ label: 'Review', href: roundHref, icon: ClipboardCheck },
|
|
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
|
|
]
|
|
case 'FILTERING':
|
|
return [
|
|
{ label: 'Run Screening', href: roundHref, icon: ClipboardCheck },
|
|
{ label: 'Review Results', href: `${roundHref}?tab=filtering`, icon: Eye },
|
|
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
|
|
]
|
|
case 'EVALUATION':
|
|
return [
|
|
{ label: 'Assignments', href: `${roundHref}?tab=assignments`, icon: Users },
|
|
{ label: 'Send Reminders', href: `${roundHref}?tab=assignments`, icon: Send },
|
|
{ label: 'Export', href: roundHref, icon: FileDown },
|
|
]
|
|
case 'SUBMISSION':
|
|
return [
|
|
{ label: 'Submissions', href: roundHref, icon: ClipboardCheck },
|
|
{ label: 'Deadlines', href: `${roundHref}?tab=config`, icon: Calendar },
|
|
{ label: 'Status', href: `${roundHref}?tab=projects`, icon: Eye },
|
|
]
|
|
case 'MENTORING':
|
|
return [
|
|
{ label: 'Mentors', href: `${roundHref}?tab=projects`, icon: Users },
|
|
{ label: 'Progress', href: roundHref, icon: Eye },
|
|
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
|
|
]
|
|
case 'LIVE_FINAL':
|
|
return [
|
|
{ label: 'Live Control', href: roundHref, icon: Presentation },
|
|
{ label: 'Results', href: `${roundHref}?tab=projects`, icon: Vote },
|
|
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
|
|
]
|
|
case 'DELIBERATION':
|
|
return [
|
|
{ label: 'Sessions', href: roundHref, icon: Play },
|
|
{ label: 'Results', href: `${roundHref}?tab=projects`, icon: Eye },
|
|
{ label: 'Lock Results', href: roundHref, icon: Lock },
|
|
]
|
|
default:
|
|
return [
|
|
{ label: 'Rounds', href: '/admin/rounds', icon: CircleDot },
|
|
{ label: 'Import', href: '/admin/projects/new', icon: Upload },
|
|
{ label: 'Invite', href: '/admin/members', icon: UserPlus },
|
|
]
|
|
}
|
|
}
|
|
|
|
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
|
|
const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
|
|
{ editionId },
|
|
{ enabled: !!editionId, retry: 1, refetchInterval: 30_000 }
|
|
)
|
|
const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery(
|
|
{ editionId, limit: 8 },
|
|
{ enabled: !!editionId, refetchInterval: 30_000 }
|
|
)
|
|
const { data: liveActivity } = trpc.dashboard.getRecentActivity.useQuery(
|
|
{ limit: 8 },
|
|
{ enabled: !!editionId, refetchInterval: 5_000 }
|
|
)
|
|
|
|
if (isLoading) {
|
|
return <DashboardSkeleton />
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
<AlertTriangle className="h-12 w-12 text-destructive/50" />
|
|
<p className="mt-2 font-medium">Failed to load dashboard</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{error.message || 'An unexpected error occurred. Please try refreshing the page.'}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
if (!data) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
|
|
<p className="mt-2 font-medium">Edition not found</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
The selected edition could not be found
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
const {
|
|
edition,
|
|
pipelineRounds,
|
|
activeRoundId,
|
|
nextActions,
|
|
projectCount,
|
|
newProjectsThisWeek,
|
|
totalJurors,
|
|
activeJurors,
|
|
evaluationStats,
|
|
totalAssignments,
|
|
latestProjects,
|
|
recentlyActiveProjects,
|
|
categoryBreakdown,
|
|
oceanIssueBreakdown,
|
|
recentActivity,
|
|
} = data
|
|
|
|
const activeRound = activeRoundId
|
|
? pipelineRounds.find((r) => r.id === activeRoundId) ?? null
|
|
: null
|
|
|
|
// Find next draft round for summary panel
|
|
const lastActiveSortOrder = Math.max(
|
|
...pipelineRounds.filter((r) => r.status === 'ROUND_ACTIVE').map((r) => r.sortOrder),
|
|
-1
|
|
)
|
|
const nextDraftRound = pipelineRounds.find(
|
|
(r) => r.status === 'ROUND_DRAFT' && r.sortOrder > lastActiveSortOrder
|
|
) ?? null
|
|
|
|
const quickActions = getContextualActions(activeRound)
|
|
|
|
return (
|
|
<>
|
|
{/* Page Header */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -6 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
|
>
|
|
<div>
|
|
<h1 className="text-xl font-bold tracking-tight md:text-2xl">
|
|
{edition.name} {edition.year}
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Welcome back, {sessionName}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{quickActions.map((action) => (
|
|
<Link key={action.label} href={action.href as Route}>
|
|
<Button size="sm" variant="outline">
|
|
<action.icon className="mr-1.5 h-3.5 w-3.5" />
|
|
{action.label}
|
|
</Button>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Competition Pipeline */}
|
|
<AnimatedCard index={0}>
|
|
<CompetitionPipeline rounds={pipelineRounds} />
|
|
</AnimatedCard>
|
|
|
|
{/* Round-Specific Stats */}
|
|
<AnimatedCard index={1}>
|
|
<RoundStats
|
|
activeRound={activeRound}
|
|
projectCount={projectCount}
|
|
newProjectsThisWeek={newProjectsThisWeek}
|
|
totalJurors={totalJurors}
|
|
activeJurors={activeJurors}
|
|
totalAssignments={totalAssignments}
|
|
evaluationStats={evaluationStats}
|
|
actionsCount={nextActions.length}
|
|
nextDraftRound={nextDraftRound ? { name: nextDraftRound.name, roundType: nextDraftRound.roundType } : null}
|
|
/>
|
|
</AnimatedCard>
|
|
|
|
{/* Two-Column Layout */}
|
|
<div className="grid gap-6 lg:grid-cols-12">
|
|
{/* Left Column */}
|
|
<div className="space-y-6 lg:col-span-8">
|
|
{activeRound && (
|
|
<AnimatedCard index={2}>
|
|
<ActiveRoundPanel round={activeRound} />
|
|
</AnimatedCard>
|
|
)}
|
|
|
|
<AnimatedCard index={3}>
|
|
<ProjectListCompact
|
|
projects={latestProjects}
|
|
activeProjects={recentlyActiveProjects}
|
|
mode={activeRound && activeRound.roundType !== 'INTAKE' ? 'active' : 'recent'}
|
|
/>
|
|
</AnimatedCard>
|
|
|
|
{recentEvals && recentEvals.length > 0 && (
|
|
<AnimatedCard index={4}>
|
|
<RecentEvaluations evaluations={recentEvals} />
|
|
</AnimatedCard>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right Column */}
|
|
<div className="space-y-6 lg:col-span-4">
|
|
<AnimatedCard index={5}>
|
|
<SmartActions actions={nextActions} />
|
|
</AnimatedCard>
|
|
|
|
<AnimatedCard index={6}>
|
|
<ActivityFeed activity={liveActivity ?? recentActivity} />
|
|
</AnimatedCard>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom Full Width */}
|
|
<div className="grid gap-6 lg:grid-cols-12">
|
|
<div className="lg:col-span-8">
|
|
<AnimatedCard index={7}>
|
|
<GeographicSummaryCard programId={editionId} />
|
|
</AnimatedCard>
|
|
</div>
|
|
<div className="lg:col-span-4">
|
|
<AnimatedCard index={8}>
|
|
<CategoryBreakdown
|
|
categories={categoryBreakdown}
|
|
issues={oceanIssueBreakdown}
|
|
/>
|
|
</AnimatedCard>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|