diff --git a/src/app/(admin)/admin/dashboard-content.tsx b/src/app/(admin)/admin/dashboard-content.tsx index 1ccf7b1..181c202 100644 --- a/src/app/(admin)/admin/dashboard-content.tsx +++ b/src/app/(admin)/admin/dashboard-content.tsx @@ -1,223 +1,33 @@ 'use client' import Link from 'next/link' -import type { Route } from 'next' 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 { Card, CardContent } from '@/components/ui/card' 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, - Waves, - Clock, - BarChart3, - Zap, } 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' 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' + type DashboardContentProps = { editionId: string sessionName: string } -function formatEntity(entityType: string | null): string { - if (!entityType) return 'record' - 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 = { - 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, ' ') -} - -function getActionIcon(action: string) { - switch (action) { - case 'CREATE': - case 'BULK_CREATE': - return - case 'UPDATE': - case 'UPDATE_STATUS': - case 'BULK_UPDATE': - case 'BULK_UPDATE_STATUS': - case 'STATUS_CHANGE': - case 'ROLE_CHANGED': - return - case 'DELETE': - case 'BULK_DELETE': - return - case 'LOGIN': - case 'LOGIN_SUCCESS': - case 'LOGIN_FAILED': - case 'PASSWORD_SET': - case 'PASSWORD_CHANGED': - case 'COMPLETE_ONBOARDING': - return - case 'EXPORT': - case 'REPORT_GENERATED': - return - case 'SUBMIT': - case 'EVALUATION_SUBMITTED': - case 'DRAFT_SUBMITTED': - return - case 'ASSIGN': - case 'BULK_ASSIGN': - case 'APPLY_SUGGESTIONS': - case 'ASSIGN_PROJECTS_TO_ROUND': - case 'MENTOR_ASSIGN': - case 'MENTOR_BULK_ASSIGN': - return - case 'INVITE': - case 'SEND_INVITATION': - case 'BULK_SEND_INVITATIONS': - return - case 'IMPORT': - return - default: - return - } -} - export function DashboardContent({ editionId, sessionName }: DashboardContentProps) { const { data, isLoading, error } = trpc.dashboard.getStats.useQuery( { editionId }, @@ -258,798 +68,126 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro const { edition, - activeRoundCount, - totalRoundCount, + pipelineRounds, + activeRoundId, + nextActions, 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 - - 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 } - }) - - 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) - - 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) - - const pendingTotal = pendingCOIs + unassignedProjects + draftRounds - const summaryParts: string[] = [] - if (activeRoundCount > 0) summaryParts.push(`${activeRoundCount} round${activeRoundCount !== 1 ? 's' : ''} active`) - if (totalAssignments - submittedCount > 0) summaryParts.push(`${totalAssignments - submittedCount} evaluations pending`) - if (pendingTotal > 0) summaryParts.push(`${pendingTotal} action${pendingTotal !== 1 ? 's' : ''} needed`) + const activeRound = activeRoundId + ? pipelineRounds.find((r) => r.id === activeRoundId) ?? null + : null return ( <> - {/* ── Header Banner ── */} + {/* Page Header */} - {/* Decorative background pattern */} -
-
-
-
+
+

+ {edition.name} {edition.year} +

+

+ Welcome back, {sessionName} +

- -
-
-
-
- -
-
-

- Welcome back, {sessionName} -

-

- {edition.name} {edition.year} — Command Center -

-
-
- {summaryParts.length > 0 && ( -

- {summaryParts.join(' \u00b7 ')} -

- )} -
- -
- - - - - - - - - -
+
+ + + + + + + + +
- {/* ── Stats Row ── */} -
- - - -
-
-

Rounds

-

{totalRoundCount}

-

- {activeRoundCount} active -

-
-
- -
-
-
-
-
+ {/* Competition Pipeline */} + + + - - - -
-
-

Projects

-

{projectCount}

-

- {newProjectsThisWeek > 0 ? `+${newProjectsThisWeek} this week` : 'In edition'} -

-
-
- -
-
-
-
-
+ {/* Round-Specific Stats */} + + + - - - -
-
-

Jury

-

{totalJurors}

-

- {activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} pending`} -

-
-
- -
-
-
-
-
- - - - -
-
-

Evaluations

-

- {submittedCount} - /{totalAssignments} -

-

- {completionRate.toFixed(0)}% complete -

-
-
- -
-
-
-
-
- - - 0 ? 'border-l-amber-500' : 'border-l-emerald-400'}`}> - -
-
-

Pending

-

{pendingTotal}

-

0 ? 'text-amber-600' : 'text-emerald-600'}`}> - {pendingTotal > 0 ? 'Actions needed' : 'All clear'} -

-
-
0 ? 'bg-amber-500/10' : 'bg-emerald-400/10'}`}> - {pendingTotal > 0 - ? - : - } -
-
-
-
-
-
- - {/* ── Main Two-Column Layout ── */} + {/* Two-Column Layout */}
- - {/* Left Column (2/3) */} + {/* Left Column */}
- - {/* Active Rounds */} - - - -
-
-
- -
-
- Active Rounds - - {edition.name} — {roundsWithEvalStats.length} round{roundsWithEvalStats.length !== 1 ? 's' : ''} - -
-
- - All rounds - -
-
- - {roundsWithEvalStats.length === 0 ? ( -
-
- -
-

- No rounds created yet -

- - - -
- ) : ( -
- {roundsWithEvalStats.map((round: typeof roundsWithEvalStats[number], idx: number) => ( - - -
-
-
-
-

{round.name}

- -
-
- - - {round._count.projectRoundStates} projects - - - - {round._count.assignments} assignments - - {round.windowOpenAt && round.windowCloseAt && ( - - - {formatDateOnly(round.windowOpenAt)} – {formatDateOnly(round.windowCloseAt)} - - )} -
-
- {round.totalEvals > 0 && ( -
-

- {round.evalPercent}% -

-

evaluated

-
- )} -
- {round.totalEvals > 0 && ( -
- -

- {round.submittedEvals} of {round.totalEvals} evaluations submitted -

-
- )} -
- -
- ))} -
- )} -
-
-
- - {/* Latest Projects */} - - - -
-
-
- -
-
- Recent Projects - Latest submissions -
-
- - All projects - -
-
- - {latestProjects.length === 0 ? ( -
-
- -
-

- No projects submitted yet -

-
- ) : ( -
- {latestProjects.map((project, idx) => ( - - -
- -
-
-

- {truncate(project.title, 50)} -

- -
-

- {[ - project.teamName, - project.country ? getCountryName(project.country) : null, - formatDateOnly(project.submittedAt || project.createdAt), - ] - .filter(Boolean) - .join(' \u00b7 ')} -

-
-
- -
- ))} -
- )} -
-
-
-
- - {/* Right Column (1/3) */} -
- - {/* Action Required */} - - 0 ? 'border-amber-200/60' : ''}> - -
-
0 ? 'bg-amber-500/10' : 'bg-emerald-500/10'}`}> - {pendingTotal > 0 - ? - : - } -
- - {pendingTotal > 0 ? 'Action Required' : 'All Clear'} - -
-
- -
- {pendingCOIs > 0 && ( - -
- - COI declarations -
- {pendingCOIs} - - )} - {unassignedProjects > 0 && ( - -
- - Unassigned projects -
- {unassignedProjects} - - )} - {draftRounds > 0 && ( - -
- - Draft rounds -
- {draftRounds} - - )} - {pendingTotal === 0 && ( -
-
- -
-

All caught up!

-

No pending actions

-
- )} -
-
-
-
- - {/* Evaluation Progress */} - - - -
-
- -
- Eval Progress -
-
- - {roundsWithEvalStats.filter((s: typeof roundsWithEvalStats[number]) => s.status !== 'ROUND_DRAFT' && s.totalEvals > 0).length === 0 ? ( -
- -

- No evaluations yet -

-
- ) : ( -
- {roundsWithEvalStats - .filter((r: typeof roundsWithEvalStats[number]) => r.status !== 'ROUND_DRAFT' && r.totalEvals > 0) - .map((round: typeof roundsWithEvalStats[number]) => ( -
-
-

{round.name}

- - {round.evalPercent}% - -
- -

- {round.submittedEvals}/{round.totalEvals} submitted -

-
- ))} -
- )} -
-
-
- - {/* Upcoming Deadlines */} - {upcomingDeadlines.length > 0 && ( - - - -
-
- -
- Deadlines -
-
- -
- {upcomingDeadlines.map((deadline, i) => { - const days = daysUntil(deadline.date) - const isUrgent = days <= 7 - return ( -
-
- -
-
-

- {deadline.roundName} -

-

- {formatDateOnly(deadline.date)} · {days}d remaining -

-
-
- ) - })} -
-
-
+ {activeRound && ( + + )} - {/* Activity Feed */} - - - -
-
- -
- Activity -
-
- - {recentActivity.length === 0 ? ( -
- -

- No recent activity -

-
- ) : ( -
- {/* Timeline line */} -
-
- {recentActivity.map((log, idx) => ( - -
- {getActionIcon(log.action)} -
-
-

- {log.user?.name || 'System'} - {' '}{formatAction(log.action, log.entityType)} -

-

- {formatRelativeTime(log.timestamp)} -

-
-
- ))} -
-
- )} - - + + + +
+ + {/* Right Column */} +
+ + + + + +
- {/* ── Bottom Full Width Section ── */} + {/* Bottom Full Width */}
- {/* Geographic Distribution */}
- +
- - {/* Category & Issue Breakdown */}
- - - -
-
- -
- Categories -
-
- - {categories.length === 0 && issues.length === 0 ? ( -
- -

- No category data -

-
- ) : ( -
- {categories.length > 0 && ( -
-

- Competition Type -

- {categories.map((cat) => ( -
-
- {cat.label} - {cat.count} -
-
- -
-
- ))} -
- )} - {issues.length > 0 && ( -
-

- Top Issues -

- {issues.map((issue) => ( -
-
- {issue.label} - {issue.count} -
-
- -
-
- ))} -
- )} -
- )} -
-
+ +
) } - -function DashboardSkeleton() { - return ( - <> - {/* Header skeleton */} - - - {/* Stats row skeleton */} -
- {[...Array(5)].map((_, i) => ( - - - - - - - - ))} -
- - {/* Two-column content skeleton */} -
-
- - - - - - -
- {[...Array(3)].map((_, i) => ( - - ))} -
-
-
- - - - - - -
- {[...Array(5)].map((_, i) => ( - - ))} -
-
-
-
-
- {[...Array(4)].map((_, i) => ( - - - -
- {[...Array(3)].map((_, j) => ( - - ))} -
-
-
- ))} -
-
- - {/* Bottom skeleton */} -
- - -
- - ) -} diff --git a/src/components/dashboard/active-round-panel.tsx b/src/components/dashboard/active-round-panel.tsx new file mode 100644 index 0000000..fc20549 --- /dev/null +++ b/src/components/dashboard/active-round-panel.tsx @@ -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 = { + INTAKE: Inbox, + FILTERING: Filter, + EVALUATION: ClipboardCheck, + SUBMISSION: Upload, + MENTORING: Users, + LIVE_FINAL: Radio, + DELIBERATION: Scale, +} + +const stateColors: Record = { + 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 ( + + + Closed + + ) + } + + return ( + + + {days === 0 ? 'Closes today' : `${days}d left`} + + ) +} + +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 ( + +
+
+ {segments.map((key) => { + const pct = (projectStates[key] / total) * 100 + const color = stateColors[key] + + return ( + + + + + + {color.label}: {projectStates[key]} ({Math.round(pct)}%) + + + ) + })} +
+
+ {segments.map((key) => { + const color = stateColors[key] + return ( +
+ + {color.label}: {projectStates[key]} +
+ ) + })} +
+
+
+ ) +} + +function RoundTypeContent({ round }: { round: PipelineRound }) { + const { projectStates } = round + + switch (round.roundType) { + case 'INTAKE': + return ( +

+ Projects are submitting documents. {projectStates.PASSED} auto-passed,{' '} + {projectStates.PENDING} pending. +

+ ) + + 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 ( +
+
+ Filtering progress + {pct}% +
+ +
+ {round.filteringPassed} passed + {round.filteringRejected} failed + {round.filteringFlagged} flagged +
+
+ ) + } + + case 'EVALUATION': { + const pct = + round.evalTotal > 0 + ? Math.round((round.evalSubmitted / round.evalTotal) * 100) + : 0 + + return ( +
+
+ Evaluation progress + + {round.evalSubmitted} / {round.evalTotal} ({pct}%) + +
+ + {round.evalDraft > 0 && ( +

+ {round.evalDraft} draft{round.evalDraft !== 1 ? 's' : ''} in progress +

+ )} +
+ ) + } + + case 'SUBMISSION': + return ( +

+ {projectStates.COMPLETED} submissions completed, {projectStates.IN_PROGRESS} in + progress, {projectStates.PENDING} pending. +

+ ) + + case 'MENTORING': + return ( +

+ Mentoring phase active. {round.assignmentCount} mentor assignment + {round.assignmentCount !== 1 ? 's' : ''} configured.{' '} + {projectStates.COMPLETED} projects completed mentoring. +

+ ) + + case 'LIVE_FINAL': + return ( +
+

+ Live finals round.{' '} + {round.liveSessionStatus + ? `Session: ${formatEnumLabel(round.liveSessionStatus)}` + : 'No session started yet.'} +

+

+ {projectStates.total} projects in round, {projectStates.COMPLETED} completed. +

+
+ ) + + case 'DELIBERATION': + return ( +

+ Deliberation phase.{' '} + {round.deliberationCount > 0 + ? `${round.deliberationCount} deliberation session${round.deliberationCount !== 1 ? 's' : ''} recorded.` + : 'No deliberation sessions yet.'} +

+ ) + + default: + return ( +

+ {projectStates.total} projects in this round. +

+ ) + } +} + +export function ActiveRoundPanel({ round }: ActiveRoundPanelProps) { + const Icon = roundTypeIcons[round.roundType] || ClipboardCheck + + return ( + + +
+ +
+
+ {round.name} +

+ {formatEnumLabel(round.roundType)} +

+
+
+ + {round.windowCloseAt && ( + + )} +
+
+ + + +
+ +
+
+
+ ) +} diff --git a/src/components/dashboard/activity-feed.tsx b/src/components/dashboard/activity-feed.tsx new file mode 100644 index 0000000..74793d2 --- /dev/null +++ b/src/components/dashboard/activity-feed.tsx @@ -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 ( + + +
+
+ +
+ Activity +
+
+ + {activity.length === 0 ? ( +
+ +

+ No recent activity +

+
+ ) : ( +
+ {/* Timeline line */} +
+
+ {activity.map((log, idx) => ( + +
+ {getActionIcon(log.action)} +
+
+

+ {log.user?.name || 'System'} + {' '}{formatAction(log.action, log.entityType)} +

+

+ {formatRelativeTime(log.timestamp)} +

+
+
+ ))} +
+
+ )} + + + ) +} diff --git a/src/components/dashboard/category-breakdown.tsx b/src/components/dashboard/category-breakdown.tsx new file mode 100644 index 0000000..53c502b --- /dev/null +++ b/src/components/dashboard/category-breakdown.tsx @@ -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 ( + + +
+
+ +
+ Categories +
+
+ + {categories.length === 0 && issues.length === 0 ? ( +
+ +

+ No category data +

+
+ ) : ( +
+ {categories.length > 0 && ( +
+

+ Competition Type +

+ {categories.map((cat) => ( +
+
+ {cat.label} + {cat.count} +
+
+ +
+
+ ))} +
+ )} + {issues.length > 0 && ( +
+

+ Top Issues +

+ {issues.map((issue) => ( +
+
+ {issue.label} + {issue.count} +
+
+ +
+
+ ))} +
+ )} +
+ )} +
+
+ ) +} diff --git a/src/components/dashboard/competition-pipeline.tsx b/src/components/dashboard/competition-pipeline.tsx new file mode 100644 index 0000000..ac80b58 --- /dev/null +++ b/src/components/dashboard/competition-pipeline.tsx @@ -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 ( + +
+ + ) +} + +export function CompetitionPipeline({ + rounds, +}: { + rounds: PipelineRound[] +}) { + if (rounds.length === 0) { + return ( + + +
+
+ +
+ Competition Pipeline +
+
+ +
+
+ +
+

+ No rounds configured yet +

+

+ Create rounds to visualize the competition pipeline. +

+
+
+
+ ) + } + + return ( + + +
+
+
+ +
+ Competition Pipeline +
+ + All rounds + +
+
+ +
+
+ {rounds.map((round, index) => ( +
+ + {index < rounds.length - 1 && ( + + )} +
+ ))} +
+
+
+
+ ) +} diff --git a/src/components/dashboard/dashboard-skeleton.tsx b/src/components/dashboard/dashboard-skeleton.tsx new file mode 100644 index 0000000..efeb402 --- /dev/null +++ b/src/components/dashboard/dashboard-skeleton.tsx @@ -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 */} + + + {/* Stats row skeleton */} +
+ {[...Array(5)].map((_, i) => ( + + + + + + + + ))} +
+ + {/* Two-column content skeleton */} +
+
+ + + + + + +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+
+
+ + + + + + +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+
+
+
+
+ {[...Array(4)].map((_, i) => ( + + + +
+ {[...Array(3)].map((_, j) => ( + + ))} +
+
+
+ ))} +
+
+ + {/* Bottom skeleton */} +
+ + +
+ + ) +} diff --git a/src/components/dashboard/pipeline-round-node.tsx b/src/components/dashboard/pipeline-round-node.tsx new file mode 100644 index 0000000..a25c5cd --- /dev/null +++ b/src/components/dashboard/pipeline-round-node.tsx @@ -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 ( + + +
+ {/* Active ping indicator */} + {isActive && ( + + + + + )} + + {/* Icon */} +
+ +
+ + {/* Name */} +

+ {round.name} +

+ + {/* Status label */} + + {status.label} + + + {/* Metric */} +

+ {metric} +

+
+ +
+ ) +} + +export type { PipelineRound } diff --git a/src/components/dashboard/project-list-compact.tsx b/src/components/dashboard/project-list-compact.tsx new file mode 100644 index 0000000..7454ac7 --- /dev/null +++ b/src/components/dashboard/project-list-compact.tsx @@ -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 ( + + +
+
+
+ +
+
+ Recent Projects + Latest submissions +
+
+ + All projects + +
+
+ + {projects.length === 0 ? ( +
+
+ +
+

+ No projects submitted yet +

+
+ ) : ( +
+ {projects.map((project, idx) => ( + + +
+ +
+
+

+ {truncate(project.title, 50)} +

+ +
+

+ {[ + project.teamName, + project.country ? getCountryName(project.country) : null, + formatDateOnly(project.submittedAt || project.createdAt), + ] + .filter(Boolean) + .join(' \u00b7 ')} +

+
+
+ +
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/components/dashboard/round-stats-evaluation.tsx b/src/components/dashboard/round-stats-evaluation.tsx new file mode 100644 index 0000000..0c00b0d --- /dev/null +++ b/src/components/dashboard/round-stats-evaluation.tsx @@ -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 ( +
+

+ {round.name} — Evaluation +

+
+ + + +
+
+

Total Assignments

+

{assignmentCount}

+

+ Jury-project pairs +

+
+
+ +
+
+
+
+
+ + + + +
+
+

Submitted

+

+ {evalSubmitted} + /{evalTotal} +

+

+ {completionPct}% complete +

+
+
+ +
+
+
+
+
+ + + + +
+
+

In Draft

+

{evalDraft}

+

+ {evalDraft > 0 ? 'Not yet submitted' : 'No drafts'} +

+
+
+ +
+
+
+
+
+ + + + +
+
+

Active Jurors

+

{activeJurors}

+

+ Evaluating this round +

+
+
+ +
+
+
+
+
+
+
+ ) +} diff --git a/src/components/dashboard/round-stats-filtering.tsx b/src/components/dashboard/round-stats-filtering.tsx new file mode 100644 index 0000000..45687d3 --- /dev/null +++ b/src/components/dashboard/round-stats-filtering.tsx @@ -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 ( +
+

+ {round.name} — Filtering +

+
+ + + +
+
+

Projects to Filter

+

{projectStates.total}

+

+ In pipeline +

+
+
+ +
+
+
+
+
+ + + + +
+
+

AI Passed

+

{filteringPassed}

+

+ {projectStates.total > 0 + ? `${((filteringPassed / projectStates.total) * 100).toFixed(0)}% pass rate` + : 'No results yet'} +

+
+
+ +
+
+
+
+
+ + + + +
+
+

AI Rejected

+

{filteringRejected}

+

+ {projectStates.total > 0 + ? `${((filteringRejected / projectStates.total) * 100).toFixed(0)}% rejected` + : 'No results yet'} +

+
+
+ +
+
+
+
+
+ + + + +
+
+

Flagged for Review

+

{filteringFlagged}

+

+ {filteringFlagged > 0 ? 'Needs manual review' : 'None flagged'} +

+
+
+ +
+
+
+
+
+
+
+ ) +} diff --git a/src/components/dashboard/round-stats-generic.tsx b/src/components/dashboard/round-stats-generic.tsx new file mode 100644 index 0000000..3042d60 --- /dev/null +++ b/src/components/dashboard/round-stats-generic.tsx @@ -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 ( +
+ + + +
+
+

Projects

+

{projectCount}

+

+ {newProjectsThisWeek > 0 ? `+${newProjectsThisWeek} this week` : 'In edition'} +

+
+
+ +
+
+
+
+
+ + + + +
+
+

Jury

+

{totalJurors}

+

+ {activeJurors} active +

+
+
+ +
+
+
+
+
+ + + + +
+
+

Evaluations

+

+ {submittedCount} + /{totalAssignments} +

+

+ {completionPct}% complete +

+
+
+ +
+
+
+
+
+ + + 0 ? 'border-l-amber-500' : 'border-l-emerald-400'}`}> + +
+
+

Actions Needed

+

{actionsCount}

+

0 ? 'text-amber-600' : 'text-emerald-600'}`}> + {actionsCount > 0 ? 'Pending actions' : 'All clear'} +

+
+
0 ? 'bg-amber-500/10' : 'bg-emerald-400/10'}`}> + {actionsCount > 0 + ? + : + } +
+
+
+
+
+
+ ) +} diff --git a/src/components/dashboard/round-stats-intake.tsx b/src/components/dashboard/round-stats-intake.tsx new file mode 100644 index 0000000..939e7d8 --- /dev/null +++ b/src/components/dashboard/round-stats-intake.tsx @@ -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 ( +
+

+ {round.name} — Intake +

+
+ + + +
+
+

Total Projects

+

{projectStates.total}

+

+ In this round +

+
+
+ +
+
+
+
+
+ + + + +
+
+

Documents Complete

+

{projectStates.PASSED}

+

+ {projectStates.total > 0 + ? `${((projectStates.PASSED / projectStates.total) * 100).toFixed(0)}% of total` + : 'No projects yet'} +

+
+
+ +
+
+
+
+
+ + + + +
+
+

Pending Review

+

{projectStates.PENDING}

+

+ {projectStates.PENDING > 0 ? 'Awaiting review' : 'All reviewed'} +

+
+
+ +
+
+
+
+
+ + + + +
+
+

New This Week

+

{newProjectsThisWeek}

+

+ {newProjectsThisWeek > 0 ? 'Recently submitted' : 'No new submissions'} +

+
+
+ +
+
+
+
+
+
+
+ ) +} diff --git a/src/components/dashboard/round-stats.tsx b/src/components/dashboard/round-stats.tsx new file mode 100644 index 0000000..04879e7 --- /dev/null +++ b/src/components/dashboard/round-stats.tsx @@ -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 ( + + ) + } + + switch (activeRound.roundType) { + case 'INTAKE': + return ( + + ) + case 'FILTERING': + return ( + + ) + case 'EVALUATION': + return ( + + ) + default: + return ( + + ) + } +} diff --git a/src/components/dashboard/smart-actions.tsx b/src/components/dashboard/smart-actions.tsx new file mode 100644 index 0000000..f896f63 --- /dev/null +++ b/src/components/dashboard/smart-actions.tsx @@ -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 = { + 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 ( + + +
+ +
+ Action Required + {actions.length > 0 && ( + {actions.length} + )} +
+ + {sorted.length === 0 ? ( +
+
+ +
+

+ All caught up! +

+
+ ) : ( +
+ {sorted.map((action) => { + const config = severityConfig[action.severity] + const Icon = config.icon + + return ( + + +
+

+ {action.title} +

+

+ {action.description} +

+
+ + + ) + })} +
+ )} +
+
+ ) +} diff --git a/src/components/dashboard/utils.ts b/src/components/dashboard/utils.ts new file mode 100644 index 0000000..e095d64 --- /dev/null +++ b/src/components/dashboard/utils.ts @@ -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 = { + 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 }) + } +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..50ee69d --- /dev/null +++ b/src/instrumentation.ts @@ -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(() => {}) + } +} diff --git a/src/server/routers/dashboard.ts b/src/server/routers/dashboard.ts index 8b1e60e..34029a1 100644 --- a/src/server/routers/dashboard.ts +++ b/src/server/routers/dashboard.ts @@ -1,11 +1,72 @@ import { z } from 'zod' 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({ /** * Get all dashboard stats in a single query batch. - * Replaces the 16 parallel Prisma queries that were previously - * run during SSR, which blocked the event loop and caused 503s. + * Returns pipeline rounds, smart actions, and supporting data. */ getStats: adminProcedure .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) + // ── All queries in parallel ────────────────────────────────────── + const [ - activeRoundCount, - totalRoundCount, + // Pipeline rounds (all, ordered by sortOrder) + 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, newProjectsThisWeek, totalJurors, activeJurors, evaluationStats, totalAssignments, - recentRounds, + // Lists latestProjects, categoryBreakdown, oceanIssueBreakdown, recentActivity, + // Action signals pendingCOIs, - draftRounds, - unassignedProjects, ] = await Promise.all([ - ctx.prisma.round.count({ - 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 } } }, - }), + // 1. All pipeline rounds ctx.prisma.round.findMany({ where: { competition: { programId: editionId } }, - orderBy: { createdAt: 'desc' }, - take: 5, + orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, - status: true, + slug: true, roundType: true, + status: true, + sortOrder: true, windowOpenAt: true, windowCloseAt: true, _count: { @@ -93,13 +131,86 @@ export const dashboardRouter = router({ 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({ where: { programId: editionId }, orderBy: { createdAt: 'desc' }, @@ -117,20 +228,24 @@ export const dashboardRouter = router({ status: true, }, }), + + // 14. Category breakdown ctx.prisma.project.groupBy({ by: ['competitionCategory'], where: { programId: editionId }, _count: true, }), + + // 15. Ocean issue breakdown ctx.prisma.project.groupBy({ by: ['oceanIssue'], where: { programId: editionId }, _count: true, }), + + // 16. Recent activity ctx.prisma.auditLog.findMany({ - where: { - timestamp: { gte: sevenDaysAgo }, - }, + where: { timestamp: { gte: sevenDaysAgo } }, orderBy: { timestamp: 'desc' }, take: 8, select: { @@ -141,6 +256,8 @@ export const dashboardRouter = router({ user: { select: { name: true } }, }, }), + + // 17. Pending COIs ctx.prisma.conflictOfInterest.count({ where: { hasConflict: true, @@ -148,40 +265,204 @@ export const dashboardRouter = router({ 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() + 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] = row._count + counts.total += row._count + } + } + + // Build eval map: roundId -> { submitted, draft, total } + const evalMap = new Map() + 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() + 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() + for (const s of liveSessions) { + if (s.roundId) liveMap.set(s.roundId, s.status) + } + + // Build deliberation map: roundId -> count + const delibMap = new Map() + 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 { edition, - activeRoundCount, - totalRoundCount, + // Pipeline + pipelineRounds, + activeRoundId, + // Smart actions + nextActions, + // Summary counts projectCount, newProjectsThisWeek, totalJurors, activeJurors, evaluationStats, totalAssignments, - recentRounds, + pendingCOIs, + // Lists latestProjects, categoryBreakdown, oceanIssueBreakdown, recentActivity, - pendingCOIs, - draftRounds, - unassignedProjects, } }), })