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

1024 lines
41 KiB
TypeScript
Raw Normal View History

'use client'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
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 { Button } from '@/components/ui/button'
import {
CircleDot,
ClipboardList,
Users,
CheckCircle2,
Calendar,
TrendingUp,
ArrowRight,
Layers,
Activity,
AlertTriangle,
ShieldAlert,
Plus,
Upload,
UserPlus,
FileEdit,
LogIn,
Send,
Eye,
Trash2,
} from 'lucide-react'
import { GeographicSummaryCard } from '@/components/charts'
import { AnimatedCard } from '@/components/shared/animated-container'
import { StatusBadge } from '@/components/shared/status-badge'
import { ProjectLogo } from '@/components/shared/project-logo'
import { getCountryName } from '@/lib/countries'
import {
formatDateOnly,
formatEnumLabel,
formatRelativeTime,
truncate,
daysUntil,
} from '@/lib/utils'
type DashboardContentProps = {
editionId: string
sessionName: string
}
function formatEntity(entityType: string | null): string {
if (!entityType) return 'record'
// Insert space before uppercase letters (PascalCase → words), then lowercase
return entityType
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.toLowerCase()
}
function formatAction(action: string, entityType: string | null): string {
const entity = formatEntity(entityType)
const actionMap: Record<string, string> = {
// Generic CRUD
CREATE: `created a ${entity}`,
UPDATE: `updated a ${entity}`,
DELETE: `deleted a ${entity}`,
IMPORT: `imported ${entity}s`,
EXPORT: `exported ${entity} data`,
REORDER: `reordered ${entity}s`,
// Auth
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',
// Evaluations
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',
// Assignments
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
STATUS_CHANGE: `changed ${entity} status`,
UPDATE_STATUS: `updated ${entity} status`,
ROLE_CHANGED: 'changed a user role',
// Invitations
INVITE: 'invited a user',
SEND_INVITATION: 'sent an invitation',
BULK_SEND_INVITATIONS: 'sent bulk invitations',
// Files
UPLOAD_FILE: 'uploaded a file',
DELETE_FILE: 'deleted a file',
REPLACE_FILE: 'replaced a file',
FILE_DOWNLOADED: 'downloaded a file',
// Filtering
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
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',
// Tags
ADD_TAG: 'added a tag',
REMOVE_TAG: 'removed a tag',
BULK_CREATE_TAGS: 'bulk created tags',
// Mentor
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',
// Messages & Webhooks
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',
// Settings
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',
// Live Voting
START_VOTING: 'started live voting',
END_SESSION: 'ended a live voting session',
UPDATE_SESSION_CONFIG: 'updated session config',
// Round Templates
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',
// Grace Period
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',
// Awards
SET_AWARD_WINNER: 'set an award winner',
// Reports & Applications
REPORT_GENERATED: 'generated a report',
DRAFT_SUBMITTED: 'submitted a draft application',
SUBMIT: `submitted a ${entity}`,
}
if (actionMap[action]) return actionMap[action]
// Fallback: convert ACTION_NAME to readable text
return action.toLowerCase().replace(/_/g, ' ')
}
function getActionIcon(action: string) {
switch (action) {
case 'CREATE':
case 'BULK_CREATE':
return <Plus className="h-3.5 w-3.5" />
case 'UPDATE':
case 'UPDATE_STATUS':
case 'BULK_UPDATE':
case 'BULK_UPDATE_STATUS':
case 'STATUS_CHANGE':
case 'ROLE_CHANGED':
return <FileEdit className="h-3.5 w-3.5" />
case 'DELETE':
case 'BULK_DELETE':
return <Trash2 className="h-3.5 w-3.5" />
case 'LOGIN':
case 'LOGIN_SUCCESS':
case 'LOGIN_FAILED':
case 'PASSWORD_SET':
case 'PASSWORD_CHANGED':
case 'COMPLETE_ONBOARDING':
return <LogIn className="h-3.5 w-3.5" />
case 'EXPORT':
case 'REPORT_GENERATED':
return <ArrowRight className="h-3.5 w-3.5" />
case 'SUBMIT':
case 'EVALUATION_SUBMITTED':
case 'DRAFT_SUBMITTED':
return <Send className="h-3.5 w-3.5" />
case 'ASSIGN':
case 'BULK_ASSIGN':
case 'APPLY_SUGGESTIONS':
case 'ASSIGN_PROJECTS_TO_ROUND':
case 'MENTOR_ASSIGN':
case 'MENTOR_BULK_ASSIGN':
return <Users className="h-3.5 w-3.5" />
case 'INVITE':
case 'SEND_INVITATION':
case 'BULK_SEND_INVITATIONS':
return <UserPlus className="h-3.5 w-3.5" />
case 'IMPORT':
return <Upload className="h-3.5 w-3.5" />
default:
return <Eye className="h-3.5 w-3.5" />
}
}
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
{ editionId },
{ enabled: !!editionId, retry: 1 }
)
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,
activeRoundCount,
totalRoundCount,
projectCount,
newProjectsThisWeek,
totalJurors,
activeJurors,
evaluationStats,
totalAssignments,
recentRounds,
latestProjects,
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
pendingCOIs,
draftRounds,
unassignedProjects,
} = data
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: typeof recentRounds[number]) => {
const submitted = round.assignments.filter(
(a: { evaluation: { status: string } | null }) => 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.windowCloseAt && new Date(round.windowCloseAt) > now) {
deadlines.push({
label: 'Window closes',
roundName: round.name,
date: new Date(round.windowCloseAt),
})
}
}
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} &mdash; {edition.name} {edition.year}
</p>
</div>
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Rounds</p>
<p className="text-2xl font-bold mt-1">{totalRoundCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
<CircleDot className="h-5 w-5 text-blue-600" />
</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-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Projects</p>
<p className="text-2xl font-bold mt-1">{projectCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{newProjectsThisWeek > 0
? `${newProjectsThisWeek} new this week`
: 'In this edition'}
</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<ClipboardList className="h-5 w-5 text-emerald-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Jury Members</p>
<p className="text-2xl font-bold mt-1">{totalJurors}</p>
<p className="text-xs text-muted-foreground mt-1">
{activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`}
</p>
</div>
<div className="rounded-xl bg-violet-50 p-3">
<Users className="h-5 w-5 text-violet-600" />
</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-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
<p className="text-2xl font-bold mt-1">
{submittedCount}
{totalAssignments > 0 && (
<span className="text-sm font-normal text-muted-foreground">
{' '}/ {totalAssignments}
</span>
)}
</p>
</div>
<div className="rounded-xl bg-brand-teal/10 p-3">
<CheckCircle2 className="h-5 w-5 text-brand-teal" />
</div>
</div>
<div className="mt-2">
<Progress value={completionRate} className="h-2" gradient />
<p className="mt-1 text-xs text-muted-foreground">
{completionRate.toFixed(0)}% completion rate
</p>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* Quick Actions */}
<div className="grid gap-3 sm:grid-cols-3">
<Link href="/admin/rounds" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
<div className="rounded-xl bg-blue-50 p-2.5 transition-colors group-hover:bg-blue-100">
<Plus className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium">Rounds</p>
<p className="text-xs text-muted-foreground">Manage competition rounds</p>
</div>
</Link>
<Link href="/admin/projects/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-emerald-500/30 hover:bg-emerald-500/5">
<div className="rounded-xl bg-emerald-50 p-2.5 transition-colors group-hover:bg-emerald-100">
<Upload className="h-4 w-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-medium">Import Projects</p>
<p className="text-xs text-muted-foreground">Upload a CSV file</p>
</div>
</Link>
<Link href="/admin/members" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-violet-500/30 hover:bg-violet-500/5">
<div className="rounded-xl bg-violet-50 p-2.5 transition-colors group-hover:bg-violet-100">
<UserPlus className="h-4 w-4 text-violet-600" />
</div>
<div>
<p className="text-sm font-medium">Invite Jury</p>
<p className="text-xs text-muted-foreground">Add jury members</p>
</div>
</Link>
</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) */}
<AnimatedCard index={4}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<CircleDot className="h-4 w-4 text-blue-500" />
</div>
Rounds
</CardTitle>
<CardDescription>
Competition 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"
className="mt-4 text-sm font-medium text-primary hover:underline"
>
Set up your rounds
</Link>
</div>
) : (
<div className="space-y-3">
{roundsWithEvalStats.map((round: typeof roundsWithEvalStats[number]) => (
<div
key={round.id}
className="block"
>
<div className="rounded-lg border p-4 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
<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>
<StatusBadge status={round.status} />
</div>
<p className="text-sm text-muted-foreground">
{round._count.projectRoundStates} projects &middot; {round._count.assignments} assignments
{round.totalEvals > 0 && (
<> &middot; {round.evalPercent}% evaluated</>
)}
</p>
{round.windowOpenAt && round.windowCloseAt && (
<p className="text-xs text-muted-foreground">
Window: {formatDateOnly(round.windowOpenAt)} &ndash; {formatDateOnly(round.windowCloseAt)}
</p>
)}
</div>
</div>
{round.totalEvals > 0 && (
<Progress value={round.evalPercent} className="mt-3 h-1.5" gradient />
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Latest Projects Card */}
<AnimatedCard index={5}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<ClipboardList className="h-4 w-4 text-emerald-500" />
</div>
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-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-sm">
<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>
<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>
{(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>
</AnimatedCard>
</div>
{/* Right Column */}
<div className="space-y-6 lg:col-span-5">
{/* Pending Actions Card */}
<AnimatedCard index={6}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-amber-500/10 p-1.5">
<AlertTriangle className="h-4 w-4 text-amber-500" />
</div>
Pending Actions
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{pendingCOIs > 0 && (
<Link href="/admin/rounds" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-amber-500" />
<span className="text-sm">COI declarations to review</span>
</div>
<Badge variant="warning">{pendingCOIs}</Badge>
</Link>
)}
{unassignedProjects > 0 && (
<Link href="/admin/projects" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-orange-500" />
<span className="text-sm">Projects without assignments</span>
</div>
<Badge variant="warning">{unassignedProjects}</Badge>
</Link>
)}
{draftRounds > 0 && (
<Link href="/admin/rounds" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<CircleDot className="h-4 w-4 text-blue-500" />
<span className="text-sm">Draft rounds to activate</span>
</div>
<Badge variant="secondary">{draftRounds}</Badge>
</Link>
)}
{pendingCOIs === 0 && unassignedProjects === 0 && draftRounds === 0 && (
<div className="flex flex-col items-center py-4 text-center">
<CheckCircle2 className="h-6 w-6 text-emerald-500" />
<p className="mt-1.5 text-sm text-muted-foreground">All caught up!</p>
</div>
)}
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Evaluation Progress Card */}
<AnimatedCard index={7}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-brand-teal/10 p-1.5">
<TrendingUp className="h-4 w-4 text-brand-teal" />
</div>
Evaluation Progress
</CardTitle>
</CardHeader>
<CardContent>
{roundsWithEvalStats.filter((s: typeof roundsWithEvalStats[number]) => s.status !== 'ROUND_DRAFT' && s.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: typeof roundsWithEvalStats[number]) => r.status !== 'ROUND_DRAFT' && r.totalEvals > 0)
.map((round: typeof roundsWithEvalStats[number]) => (
<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" gradient />
<p className="text-xs text-muted-foreground">
{round.submittedEvals} of {round.totalEvals} evaluations submitted
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Category Breakdown Card */}
<AnimatedCard index={8}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Layers className="h-4 w-4 text-violet-500" />
</div>
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-brand-teal transition-all"
style={{ width: `${(issue.count / maxIssueCount) * 100}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Recent Activity Card */}
<AnimatedCard index={9}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<Activity className="h-4 w-4 text-blue-500" />
</div>
Recent Activity
</CardTitle>
</CardHeader>
<CardContent>
{recentActivity.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Activity className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No recent activity
</p>
</div>
) : (
<div className="space-y-3">
{recentActivity.map((log) => (
<div key={log.id} className="flex items-start gap-3">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
{getActionIcon(log.action)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm">
<span className="font-medium">{log.user?.name || 'System'}</span>
{' '}{formatAction(log.action, log.entityType)}
</p>
<p className="text-xs text-muted-foreground">
{formatRelativeTime(log.timestamp)}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Upcoming Deadlines Card */}
<AnimatedCard index={10}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-rose-500/10 p-1.5">
<Calendar className="h-4 w-4 text-rose-500" />
</div>
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} &mdash; {deadline.roundName}
</p>
<p className={`text-xs ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`}>
{formatDateOnly(deadline.date)} &middot; in {days} day{days !== 1 ? 's' : ''}
</p>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
</div>
{/* Geographic Distribution (full width, at the bottom) */}
<GeographicSummaryCard programId={editionId} />
</>
)
}
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" />
</>
)
}