Files
MOPC-Portal/src/app/(admin)/admin/dashboard-content.tsx

305 lines
10 KiB
TypeScript
Raw Normal View History

'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'
import { RoundUserTracker } from '@/components/dashboard/round-user-tracker'
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, refetchInterval: 60_000 }
)
const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery(
{ editionId, limit: 8 },
{ enabled: !!editionId, refetchInterval: 60_000 }
)
const { data: liveActivity } = trpc.dashboard.getRecentActivity.useQuery(
{ limit: 8 },
{ enabled: !!editionId, refetchInterval: 30_000 }
)
// Round User Tracker is self-contained — it fetches its own data
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}>
<RoundUserTracker editionId={editionId} />
</AnimatedCard>
<AnimatedCard index={7}>
<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={8}>
<GeographicSummaryCard programId={editionId} />
</AnimatedCard>
</div>
<div className="lg:col-span-4">
<AnimatedCard index={9}>
<CategoryBreakdown
categories={categoryBreakdown}
issues={oceanIssueBreakdown}
/>
</AnimatedCard>
</div>
</div>
</>
)
}