2026-01-30 13:41:32 +01:00
|
|
|
import type { Metadata } from 'next'
|
|
|
|
|
import { Suspense } from 'react'
|
|
|
|
|
import { auth } from '@/lib/auth'
|
|
|
|
|
import { prisma } from '@/lib/prisma'
|
|
|
|
|
import Link from 'next/link'
|
|
|
|
|
|
|
|
|
|
export const metadata: Metadata = { title: 'Admin Dashboard' }
|
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
CardContent,
|
|
|
|
|
CardDescription,
|
|
|
|
|
CardHeader,
|
|
|
|
|
CardTitle,
|
|
|
|
|
} from '@/components/ui/card'
|
|
|
|
|
import { Badge } from '@/components/ui/badge'
|
|
|
|
|
import { Progress } from '@/components/ui/progress'
|
|
|
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
|
|
|
import {
|
|
|
|
|
CircleDot,
|
|
|
|
|
ClipboardList,
|
|
|
|
|
Users,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
Calendar,
|
|
|
|
|
TrendingUp,
|
|
|
|
|
ArrowRight,
|
|
|
|
|
Layers,
|
|
|
|
|
} from 'lucide-react'
|
|
|
|
|
import { GeographicSummaryCard } from '@/components/charts'
|
|
|
|
|
import { ProjectLogo } from '@/components/shared/project-logo'
|
|
|
|
|
import { getCountryName } from '@/lib/countries'
|
|
|
|
|
import {
|
|
|
|
|
formatDateOnly,
|
|
|
|
|
formatEnumLabel,
|
|
|
|
|
truncate,
|
|
|
|
|
daysUntil,
|
|
|
|
|
} from '@/lib/utils'
|
|
|
|
|
|
|
|
|
|
type DashboardStatsProps = {
|
|
|
|
|
editionId: string | null
|
|
|
|
|
sessionName: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
|
|
|
|
SUBMITTED: 'secondary',
|
|
|
|
|
ELIGIBLE: 'default',
|
|
|
|
|
ASSIGNED: 'default',
|
|
|
|
|
UNDER_REVIEW: 'default',
|
|
|
|
|
SHORTLISTED: 'success',
|
|
|
|
|
SEMIFINALIST: 'success',
|
|
|
|
|
FINALIST: 'success',
|
|
|
|
|
WINNER: 'success',
|
|
|
|
|
REJECTED: 'destructive',
|
|
|
|
|
WITHDRAWN: 'secondary',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
|
|
|
|
|
if (!editionId) {
|
|
|
|
|
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">No edition selected</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Select an edition from the sidebar to view dashboard
|
|
|
|
|
</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
try {
|
2026-01-30 13:41:32 +01:00
|
|
|
const edition = await prisma.program.findUnique({
|
|
|
|
|
where: { id: editionId },
|
|
|
|
|
select: { name: true, year: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!edition) {
|
|
|
|
|
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 sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
|
|
|
|
|
|
|
|
|
const [
|
|
|
|
|
activeRoundCount,
|
|
|
|
|
totalRoundCount,
|
|
|
|
|
projectCount,
|
|
|
|
|
newProjectsThisWeek,
|
|
|
|
|
totalJurors,
|
|
|
|
|
activeJurors,
|
|
|
|
|
evaluationStats,
|
|
|
|
|
totalAssignments,
|
|
|
|
|
recentRounds,
|
|
|
|
|
latestProjects,
|
|
|
|
|
categoryBreakdown,
|
|
|
|
|
oceanIssueBreakdown,
|
|
|
|
|
] = await Promise.all([
|
|
|
|
|
prisma.round.count({
|
|
|
|
|
where: { programId: editionId, status: 'ACTIVE' },
|
|
|
|
|
}),
|
|
|
|
|
prisma.round.count({
|
|
|
|
|
where: { programId: editionId },
|
|
|
|
|
}),
|
|
|
|
|
prisma.project.count({
|
|
|
|
|
where: { round: { programId: editionId } },
|
|
|
|
|
}),
|
|
|
|
|
prisma.project.count({
|
|
|
|
|
where: {
|
|
|
|
|
round: { programId: editionId },
|
|
|
|
|
createdAt: { gte: sevenDaysAgo },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
prisma.user.count({
|
|
|
|
|
where: {
|
|
|
|
|
role: 'JURY_MEMBER',
|
|
|
|
|
status: { in: ['ACTIVE', 'INVITED'] },
|
|
|
|
|
assignments: { some: { round: { programId: editionId } } },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
prisma.user.count({
|
|
|
|
|
where: {
|
|
|
|
|
role: 'JURY_MEMBER',
|
|
|
|
|
status: 'ACTIVE',
|
|
|
|
|
assignments: { some: { round: { programId: editionId } } },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
prisma.evaluation.groupBy({
|
|
|
|
|
by: ['status'],
|
|
|
|
|
where: { assignment: { round: { programId: editionId } } },
|
|
|
|
|
_count: true,
|
|
|
|
|
}),
|
|
|
|
|
prisma.assignment.count({
|
|
|
|
|
where: { round: { programId: editionId } },
|
|
|
|
|
}),
|
|
|
|
|
prisma.round.findMany({
|
|
|
|
|
where: { programId: editionId },
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
take: 5,
|
|
|
|
|
include: {
|
|
|
|
|
_count: {
|
|
|
|
|
select: {
|
|
|
|
|
projects: true,
|
|
|
|
|
assignments: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
assignments: {
|
|
|
|
|
select: {
|
|
|
|
|
evaluation: { select: { status: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
prisma.project.findMany({
|
|
|
|
|
where: { round: { programId: editionId } },
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
take: 8,
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
teamName: true,
|
|
|
|
|
status: true,
|
|
|
|
|
country: true,
|
|
|
|
|
competitionCategory: true,
|
|
|
|
|
oceanIssue: true,
|
|
|
|
|
logoKey: true,
|
|
|
|
|
createdAt: true,
|
|
|
|
|
submittedAt: true,
|
|
|
|
|
round: { select: { name: true } },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
prisma.project.groupBy({
|
|
|
|
|
by: ['competitionCategory'],
|
|
|
|
|
where: { round: { programId: editionId } },
|
|
|
|
|
_count: true,
|
|
|
|
|
}),
|
|
|
|
|
prisma.project.groupBy({
|
|
|
|
|
by: ['oceanIssue'],
|
|
|
|
|
where: { round: { programId: editionId } },
|
|
|
|
|
_count: true,
|
|
|
|
|
}),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const submittedCount =
|
|
|
|
|
evaluationStats.find((e) => e.status === 'SUBMITTED')?._count || 0
|
|
|
|
|
const draftCount =
|
|
|
|
|
evaluationStats.find((e) => e.status === 'DRAFT')?._count || 0
|
|
|
|
|
const totalEvaluations = submittedCount + draftCount
|
|
|
|
|
const completionRate =
|
|
|
|
|
totalEvaluations > 0 ? (submittedCount / totalEvaluations) * 100 : 0
|
|
|
|
|
|
|
|
|
|
const invitedJurors = totalJurors - activeJurors
|
|
|
|
|
|
|
|
|
|
// Compute per-round eval stats
|
|
|
|
|
const roundsWithEvalStats = recentRounds.map((round) => {
|
|
|
|
|
const submitted = round.assignments.filter(
|
|
|
|
|
(a) => a.evaluation?.status === 'SUBMITTED'
|
|
|
|
|
).length
|
|
|
|
|
const total = round._count.assignments
|
|
|
|
|
const percent = total > 0 ? Math.round((submitted / total) * 100) : 0
|
|
|
|
|
return { ...round, submittedEvals: submitted, totalEvals: total, evalPercent: percent }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Upcoming deadlines from rounds
|
|
|
|
|
const now = new Date()
|
|
|
|
|
const deadlines: { label: string; roundName: string; date: Date }[] = []
|
|
|
|
|
for (const round of recentRounds) {
|
|
|
|
|
if (round.votingEndAt && new Date(round.votingEndAt) > now) {
|
|
|
|
|
deadlines.push({
|
|
|
|
|
label: 'Voting closes',
|
|
|
|
|
roundName: round.name,
|
|
|
|
|
date: new Date(round.votingEndAt),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if (round.submissionEndDate && new Date(round.submissionEndDate) > now) {
|
|
|
|
|
deadlines.push({
|
|
|
|
|
label: 'Submissions close',
|
|
|
|
|
roundName: round.name,
|
|
|
|
|
date: new Date(round.submissionEndDate),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
deadlines.sort((a, b) => a.date.getTime() - b.date.getTime())
|
|
|
|
|
const upcomingDeadlines = deadlines.slice(0, 4)
|
|
|
|
|
|
|
|
|
|
// Category/issue bars
|
|
|
|
|
const categories = categoryBreakdown
|
|
|
|
|
.filter((c) => c.competitionCategory !== null)
|
|
|
|
|
.map((c) => ({
|
|
|
|
|
label: formatEnumLabel(c.competitionCategory!),
|
|
|
|
|
count: c._count,
|
|
|
|
|
}))
|
|
|
|
|
.sort((a, b) => b.count - a.count)
|
|
|
|
|
|
|
|
|
|
const issues = oceanIssueBreakdown
|
|
|
|
|
.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 (
|
|
|
|
|
<>
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
|
|
|
|
<p className="text-muted-foreground">
|
|
|
|
|
Welcome back, {sessionName} — {edition.name} {edition.year}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Stats Grid */}
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
|
|
<CardTitle className="text-sm font-medium">Rounds</CardTitle>
|
|
|
|
|
<CircleDot className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="text-2xl font-bold">{totalRoundCount}</div>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
|
|
|
|
|
</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
|
|
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
|
|
|
|
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="text-2xl font-bold">{projectCount}</div>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
{newProjectsThisWeek > 0
|
|
|
|
|
? `${newProjectsThisWeek} new this week`
|
|
|
|
|
: 'In this edition'}
|
|
|
|
|
</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
|
|
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
|
|
|
|
|
<Users className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="text-2xl font-bold">{totalJurors}</div>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
{activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`}
|
|
|
|
|
</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
|
|
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
|
|
|
|
|
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="text-2xl font-bold">
|
|
|
|
|
{submittedCount}
|
|
|
|
|
{totalAssignments > 0 && (
|
|
|
|
|
<span className="text-sm font-normal text-muted-foreground">
|
|
|
|
|
{' '}/ {totalAssignments}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2">
|
|
|
|
|
<Progress value={completionRate} className="h-2" />
|
|
|
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
{completionRate.toFixed(0)}% completion rate
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Two-Column Content */}
|
|
|
|
|
<div className="grid gap-6 lg:grid-cols-12">
|
|
|
|
|
{/* Left Column */}
|
|
|
|
|
<div className="space-y-6 lg:col-span-7">
|
|
|
|
|
{/* Rounds Card (enhanced) */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<CardTitle>Rounds</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
Voting rounds in {edition.name}
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</div>
|
|
|
|
|
<Link
|
|
|
|
|
href="/admin/rounds"
|
|
|
|
|
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
|
|
|
|
>
|
|
|
|
|
View all <ArrowRight className="h-3.5 w-3.5" />
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
{roundsWithEvalStats.length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
|
|
|
|
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
|
|
|
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
|
|
|
No rounds created yet
|
|
|
|
|
</p>
|
|
|
|
|
<Link
|
|
|
|
|
href="/admin/rounds/new"
|
|
|
|
|
className="mt-4 text-sm font-medium text-primary hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Create your first round
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{roundsWithEvalStats.map((round) => (
|
|
|
|
|
<Link
|
|
|
|
|
key={round.id}
|
|
|
|
|
href={`/admin/rounds/${round.id}`}
|
|
|
|
|
className="block"
|
|
|
|
|
>
|
|
|
|
|
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
|
|
|
|
|
<div className="flex items-start justify-between gap-2">
|
|
|
|
|
<div className="space-y-1.5 flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<p className="font-medium">{round.name}</p>
|
|
|
|
|
<Badge
|
|
|
|
|
variant={
|
|
|
|
|
round.status === 'ACTIVE'
|
|
|
|
|
? 'default'
|
|
|
|
|
: round.status === 'CLOSED'
|
|
|
|
|
? 'success'
|
|
|
|
|
: 'secondary'
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{round.status}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
{round._count.projects} projects · {round._count.assignments} assignments
|
|
|
|
|
{round.totalEvals > 0 && (
|
|
|
|
|
<> · {round.evalPercent}% evaluated</>
|
|
|
|
|
)}
|
|
|
|
|
</p>
|
|
|
|
|
{round.votingStartAt && round.votingEndAt && (
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Voting: {formatDateOnly(round.votingStartAt)} – {formatDateOnly(round.votingEndAt)}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{round.totalEvals > 0 && (
|
|
|
|
|
<Progress value={round.evalPercent} className="mt-3 h-1.5" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Latest Projects Card */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<CardTitle>Latest Projects</CardTitle>
|
|
|
|
|
<CardDescription>Recently submitted projects</CardDescription>
|
|
|
|
|
</div>
|
|
|
|
|
<Link
|
|
|
|
|
href="/admin/projects"
|
|
|
|
|
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
|
|
|
|
>
|
|
|
|
|
View all <ArrowRight className="h-3.5 w-3.5" />
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
{latestProjects.length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
|
|
|
|
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
|
|
|
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
|
|
|
No projects submitted yet
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
{latestProjects.map((project) => (
|
|
|
|
|
<Link
|
|
|
|
|
key={project.id}
|
|
|
|
|
href={`/admin/projects/${project.id}`}
|
|
|
|
|
className="block"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-start gap-3 rounded-lg p-3 transition-colors hover:bg-muted/50">
|
|
|
|
|
<ProjectLogo
|
|
|
|
|
project={project}
|
|
|
|
|
size="sm"
|
|
|
|
|
fallback="initials"
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-start justify-between gap-2">
|
|
|
|
|
<p className="font-medium text-sm leading-tight truncate">
|
|
|
|
|
{truncate(project.title, 45)}
|
|
|
|
|
</p>
|
|
|
|
|
<Badge
|
|
|
|
|
variant={statusColors[project.status] || 'secondary'}
|
|
|
|
|
className="shrink-0 text-[10px] px-1.5 py-0"
|
|
|
|
|
>
|
|
|
|
|
{project.status.replace('_', ' ')}
|
|
|
|
|
</Badge>
|
|
|
|
|
</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>
|
|
|
|
|
{(project.competitionCategory || project.oceanIssue) && (
|
|
|
|
|
<p className="text-xs text-muted-foreground/70 mt-0.5">
|
|
|
|
|
{[
|
|
|
|
|
project.competitionCategory
|
|
|
|
|
? formatEnumLabel(project.competitionCategory)
|
|
|
|
|
: null,
|
|
|
|
|
project.oceanIssue
|
|
|
|
|
? formatEnumLabel(project.oceanIssue)
|
|
|
|
|
: null,
|
|
|
|
|
]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(' \u00b7 ')}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Right Column */}
|
|
|
|
|
<div className="space-y-6 lg:col-span-5">
|
|
|
|
|
{/* Evaluation Progress Card */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
<TrendingUp className="h-4 w-4" />
|
|
|
|
|
Evaluation Progress
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
{roundsWithEvalStats.filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0).length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
|
|
|
|
<TrendingUp className="h-8 w-8 text-muted-foreground/40" />
|
|
|
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
|
|
|
No evaluations in progress
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
{roundsWithEvalStats
|
|
|
|
|
.filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0)
|
|
|
|
|
.map((round) => (
|
|
|
|
|
<div key={round.id} className="space-y-2">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<p className="text-sm font-medium truncate">{round.name}</p>
|
|
|
|
|
<span className="text-sm font-semibold tabular-nums">
|
|
|
|
|
{round.evalPercent}%
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<Progress value={round.evalPercent} className="h-2" />
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
{round.submittedEvals} of {round.totalEvals} evaluations submitted
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Category Breakdown Card */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
<Layers className="h-4 w-4" />
|
|
|
|
|
Project Categories
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
{categories.length === 0 && issues.length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
|
|
|
|
<Layers className="h-8 w-8 text-muted-foreground/40" />
|
|
|
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
|
|
|
No category data available
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
{categories.length > 0 && (
|
|
|
|
|
<div className="space-y-2.5">
|
|
|
|
|
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
|
|
|
By Type
|
|
|
|
|
</p>
|
|
|
|
|
{categories.map((cat) => (
|
|
|
|
|
<div key={cat.label} className="space-y-1">
|
|
|
|
|
<div className="flex items-center justify-between text-sm">
|
|
|
|
|
<span>{cat.label}</span>
|
|
|
|
|
<span className="font-medium tabular-nums">{cat.count}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
|
|
|
|
<div
|
|
|
|
|
className="h-full rounded-full bg-primary transition-all"
|
|
|
|
|
style={{ width: `${(cat.count / maxCategoryCount) * 100}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{issues.length > 0 && (
|
|
|
|
|
<div className="space-y-2.5">
|
|
|
|
|
<p className="text-xs font-medium uppercase tracking-wider 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-sm">
|
|
|
|
|
<span className="truncate mr-2">{issue.label}</span>
|
|
|
|
|
<span className="font-medium tabular-nums">{issue.count}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
|
|
|
|
<div
|
|
|
|
|
className="h-full rounded-full bg-accent transition-all"
|
|
|
|
|
style={{ width: `${(issue.count / maxIssueCount) * 100}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Upcoming Deadlines Card */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
<Calendar className="h-4 w-4" />
|
|
|
|
|
Upcoming Deadlines
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
{upcomingDeadlines.length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
|
|
|
|
<Calendar className="h-8 w-8 text-muted-foreground/40" />
|
|
|
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
|
|
|
No upcoming deadlines
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{upcomingDeadlines.map((deadline, i) => {
|
|
|
|
|
const days = daysUntil(deadline.date)
|
|
|
|
|
const isUrgent = days <= 7
|
|
|
|
|
return (
|
|
|
|
|
<div key={i} className="flex items-start gap-3">
|
|
|
|
|
<div className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${isUrgent ? 'bg-destructive/10' : 'bg-muted'}`}>
|
|
|
|
|
<Calendar className={`h-4 w-4 ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
<p className="text-sm font-medium">
|
|
|
|
|
{deadline.label} — {deadline.roundName}
|
|
|
|
|
</p>
|
|
|
|
|
<p className={`text-xs ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`}>
|
|
|
|
|
{formatDateOnly(deadline.date)} · in {days} day{days !== 1 ? 's' : ''}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Geographic Distribution (full width, at the bottom) */}
|
|
|
|
|
<GeographicSummaryCard programId={editionId} />
|
|
|
|
|
</>
|
|
|
|
|
)
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Dashboard data load failed:', err)
|
|
|
|
|
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">Dashboard temporarily unavailable</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Could not load dashboard data. Please refresh the page.
|
|
|
|
|
</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-01-30 13:41:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function DashboardSkeleton() {
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{/* Header skeleton */}
|
|
|
|
|
<div>
|
|
|
|
|
<Skeleton className="h-8 w-40" />
|
|
|
|
|
<Skeleton className="mt-2 h-4 w-64" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Stats grid skeleton */}
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
|
|
|
{[...Array(4)].map((_, i) => (
|
|
|
|
|
<Card key={i}>
|
|
|
|
|
<CardHeader className="space-y-0 pb-2">
|
|
|
|
|
<Skeleton className="h-4 w-20" />
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<Skeleton className="h-8 w-16" />
|
|
|
|
|
<Skeleton className="mt-2 h-3 w-24" />
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Two-column content skeleton */}
|
|
|
|
|
<div className="grid gap-6 lg:grid-cols-12">
|
|
|
|
|
<div className="space-y-6 lg:col-span-7">
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<Skeleton className="h-6 w-32" />
|
|
|
|
|
<Skeleton className="h-4 w-48" />
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{[...Array(3)].map((_, i) => (
|
|
|
|
|
<Skeleton key={i} className="h-24 w-full" />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<Skeleton className="h-6 w-40" />
|
|
|
|
|
<Skeleton className="h-4 w-52" />
|
|
|
|
|
</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-5">
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader><Skeleton className="h-6 w-40" /></CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{[...Array(2)].map((_, i) => (
|
|
|
|
|
<Skeleton key={i} className="h-16 w-full" />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader><Skeleton className="h-6 w-40" /></CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{[...Array(4)].map((_, i) => (
|
|
|
|
|
<Skeleton key={i} className="h-10 w-full" />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader><Skeleton className="h-6 w-40" /></CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{[...Array(2)].map((_, i) => (
|
|
|
|
|
<Skeleton key={i} className="h-12 w-full" />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Map skeleton */}
|
|
|
|
|
<Skeleton className="h-[450px] w-full rounded-lg" />
|
|
|
|
|
</>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PageProps = {
|
|
|
|
|
searchParams: Promise<{ edition?: string }>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
|
|
|
|
const [session, params] = await Promise.all([
|
|
|
|
|
auth(),
|
|
|
|
|
searchParams,
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
let editionId = params.edition || null
|
|
|
|
|
|
|
|
|
|
if (!editionId) {
|
|
|
|
|
const defaultEdition = await prisma.program.findFirst({
|
|
|
|
|
where: { status: 'ACTIVE' },
|
|
|
|
|
orderBy: { year: 'desc' },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
editionId = defaultEdition?.id || null
|
|
|
|
|
|
|
|
|
|
if (!editionId) {
|
|
|
|
|
const anyEdition = await prisma.program.findFirst({
|
|
|
|
|
orderBy: { year: 'desc' },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
editionId = anyEdition?.id || null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sessionName = session?.user?.name || 'Admin'
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<Suspense fallback={<DashboardSkeleton />}>
|
|
|
|
|
<DashboardStats editionId={editionId} sessionName={sessionName} />
|
|
|
|
|
</Suspense>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|