Redesign admin dashboard: pipeline view, round-specific stats, smart actions
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m18s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m18s
Complete rewrite of the admin dashboard replacing the 1056-line monolith with modular components. Key improvements: - Competition pipeline: horizontal visualization of all rounds in pipeline order (by sortOrder, not creation date) with type-specific icons and metrics - Round-specific stats: stat cards dynamically change based on active round type (INTAKE shows submissions/docs, FILTERING shows pass/fail/flagged, EVALUATION shows assignments/completion, fallback shows generic project/jury stats) - Smart actions: context-aware "Action Required" panel that only flags the next draft round (not all), only flags unassigned projects for EVALUATION rounds, includes deadline warnings with severity levels (critical/warning/info) - Active round panel: detailed view with project state bar, type-specific content, and deadline countdown - Extracted components: project list, activity feed, category breakdown, skeleton Backend enriched with 17 parallel queries building rich PipelineRound data including per-round project states, eval stats, filtering stats, live session status, and deliberation counts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
302
src/components/dashboard/active-round-panel.tsx
Normal file
302
src/components/dashboard/active-round-panel.tsx
Normal file
@@ -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<string, React.ElementType> = {
|
||||
INTAKE: Inbox,
|
||||
FILTERING: Filter,
|
||||
EVALUATION: ClipboardCheck,
|
||||
SUBMISSION: Upload,
|
||||
MENTORING: Users,
|
||||
LIVE_FINAL: Radio,
|
||||
DELIBERATION: Scale,
|
||||
}
|
||||
|
||||
const stateColors: Record<string, { bg: string; label: string }> = {
|
||||
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 (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Closed
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={days <= 2 ? 'destructive' : days <= 7 ? 'warning' : 'info'}
|
||||
className="gap-1"
|
||||
>
|
||||
<Clock className="h-3 w-3" />
|
||||
{days === 0 ? 'Closes today' : `${days}d left`}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<TooltipProvider>
|
||||
<div className="space-y-2">
|
||||
<div className="flex h-3 w-full overflow-hidden rounded-full bg-muted">
|
||||
{segments.map((key) => {
|
||||
const pct = (projectStates[key] / total) * 100
|
||||
const color = stateColors[key]
|
||||
|
||||
return (
|
||||
<Tooltip key={key}>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
className={cn('h-full', color.bg)}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{color.label}: {projectStates[key]} ({Math.round(pct)}%)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{segments.map((key) => {
|
||||
const color = stateColors[key]
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className={cn('inline-block h-2 w-2 rounded-full', color.bg)} />
|
||||
{color.label}: {projectStates[key]}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function RoundTypeContent({ round }: { round: PipelineRound }) {
|
||||
const { projectStates } = round
|
||||
|
||||
switch (round.roundType) {
|
||||
case 'INTAKE':
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Projects are submitting documents. {projectStates.PASSED} auto-passed,{' '}
|
||||
{projectStates.PENDING} pending.
|
||||
</p>
|
||||
)
|
||||
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Filtering progress</span>
|
||||
<span className="font-medium">{pct}%</span>
|
||||
</div>
|
||||
<Progress value={pct} gradient />
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
<span className="text-emerald-600">{round.filteringPassed} passed</span>
|
||||
<span className="text-red-600">{round.filteringRejected} failed</span>
|
||||
<span className="text-amber-600">{round.filteringFlagged} flagged</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'EVALUATION': {
|
||||
const pct =
|
||||
round.evalTotal > 0
|
||||
? Math.round((round.evalSubmitted / round.evalTotal) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Evaluation progress</span>
|
||||
<span className="font-medium">
|
||||
{round.evalSubmitted} / {round.evalTotal} ({pct}%)
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={pct} gradient />
|
||||
{round.evalDraft > 0 && (
|
||||
<p className="text-xs text-amber-600">
|
||||
{round.evalDraft} draft{round.evalDraft !== 1 ? 's' : ''} in progress
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'SUBMISSION':
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{projectStates.COMPLETED} submissions completed, {projectStates.IN_PROGRESS} in
|
||||
progress, {projectStates.PENDING} pending.
|
||||
</p>
|
||||
)
|
||||
|
||||
case 'MENTORING':
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Mentoring phase active. {round.assignmentCount} mentor assignment
|
||||
{round.assignmentCount !== 1 ? 's' : ''} configured.{' '}
|
||||
{projectStates.COMPLETED} projects completed mentoring.
|
||||
</p>
|
||||
)
|
||||
|
||||
case 'LIVE_FINAL':
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Live finals round.{' '}
|
||||
{round.liveSessionStatus
|
||||
? `Session: ${formatEnumLabel(round.liveSessionStatus)}`
|
||||
: 'No session started yet.'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{projectStates.total} projects in round, {projectStates.COMPLETED} completed.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'DELIBERATION':
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Deliberation phase.{' '}
|
||||
{round.deliberationCount > 0
|
||||
? `${round.deliberationCount} deliberation session${round.deliberationCount !== 1 ? 's' : ''} recorded.`
|
||||
: 'No deliberation sessions yet.'}
|
||||
</p>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{projectStates.total} projects in this round.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function ActiveRoundPanel({ round }: ActiveRoundPanelProps) {
|
||||
const Icon = roundTypeIcons[round.roundType] || ClipboardCheck
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-brand-blue/10">
|
||||
<Icon className="h-5 w-5 text-brand-blue" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate">{round.name}</CardTitle>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{formatEnumLabel(round.roundType)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={round.status} size="sm" />
|
||||
{round.windowCloseAt && (
|
||||
<DeadlineCountdown date={new Date(round.windowCloseAt)} />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ProjectStateBar projectStates={round.projectStates} />
|
||||
<RoundTypeContent round={round} />
|
||||
<div className="pt-1">
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/admin/rounds/${round.id}`}>
|
||||
Manage Round
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
76
src/components/dashboard/activity-feed.tsx
Normal file
76
src/components/dashboard/activity-feed.tsx
Normal file
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10">
|
||||
<Activity className="h-4 w-4 text-brand-blue" />
|
||||
</div>
|
||||
<CardTitle className="text-base">Activity</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activity.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<Activity className="h-8 w-8 text-muted-foreground/30" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
No recent activity
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-[13px] top-2 bottom-2 w-px bg-border" />
|
||||
<div className="space-y-3">
|
||||
{activity.map((log, idx) => (
|
||||
<motion.div
|
||||
key={log.id}
|
||||
initial={{ opacity: 0, x: 6 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.2 + idx * 0.03 }}
|
||||
className="relative flex items-start gap-3"
|
||||
>
|
||||
<div className="relative z-10 flex h-[26px] w-[26px] shrink-0 items-center justify-center rounded-full border-2 border-background bg-muted">
|
||||
{getActionIcon(log.action)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<p className="text-xs leading-relaxed">
|
||||
<span className="font-semibold">{log.user?.name || 'System'}</span>
|
||||
{' '}{formatAction(log.action, log.entityType)}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{formatRelativeTime(log.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
110
src/components/dashboard/category-breakdown.tsx
Normal file
110
src/components/dashboard/category-breakdown.tsx
Normal file
@@ -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 (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/10">
|
||||
<Layers className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
<CardTitle className="text-base">Categories</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{categories.length === 0 && issues.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Layers className="h-8 w-8 text-muted-foreground/30" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
No category data
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{categories.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
Competition Type
|
||||
</p>
|
||||
{categories.map((cat) => (
|
||||
<div key={cat.label} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="truncate mr-2">{cat.label}</span>
|
||||
<span className="font-bold tabular-nums">{cat.count}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-brand-blue to-brand-teal"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(cat.count / maxCategoryCount) * 100}%` }}
|
||||
transition={{ duration: 0.6, delay: 0.3, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{issues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest 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-xs">
|
||||
<span className="truncate mr-2">{issue.label}</span>
|
||||
<span className="font-bold tabular-nums">{issue.count}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-teal-light"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(issue.count / maxIssueCount) * 100}%` }}
|
||||
transition={{ duration: 0.6, delay: 0.4, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
110
src/components/dashboard/competition-pipeline.tsx
Normal file
110
src/components/dashboard/competition-pipeline.tsx
Normal file
@@ -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 (
|
||||
<motion.div
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 + index * 0.06 }}
|
||||
className="flex items-center self-center origin-left"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-0.5 w-6',
|
||||
isCompleted ? 'bg-emerald-300' : 'bg-slate-200'
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CompetitionPipeline({
|
||||
rounds,
|
||||
}: {
|
||||
rounds: PipelineRound[]
|
||||
}) {
|
||||
if (rounds.length === 0) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-500/10">
|
||||
<Workflow className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
<CardTitle className="text-base">Competition Pipeline</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<Workflow className="h-7 w-7 text-muted-foreground/40" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-muted-foreground">
|
||||
No rounds configured yet
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Create rounds to visualize the competition pipeline.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-teal/10">
|
||||
<Workflow className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
<CardTitle className="text-base">Competition Pipeline</CardTitle>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/rounds"
|
||||
className="flex items-center gap-1 text-xs font-semibold uppercase tracking-wider text-brand-teal hover:text-brand-teal-light transition-colors"
|
||||
>
|
||||
All rounds <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto pb-2">
|
||||
<div className="flex items-start gap-0 min-w-max">
|
||||
{rounds.map((round, index) => (
|
||||
<div key={round.id} className="flex items-start">
|
||||
<PipelineRoundNode round={round} index={index} />
|
||||
{index < rounds.length - 1 && (
|
||||
<Connector
|
||||
prevStatus={round.status}
|
||||
index={index}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
78
src/components/dashboard/dashboard-skeleton.tsx
Normal file
78
src/components/dashboard/dashboard-skeleton.tsx
Normal file
@@ -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 */}
|
||||
<Skeleton className="h-32 w-full rounded-2xl" />
|
||||
|
||||
{/* Stats row skeleton */}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Card key={i} className="border-l-4 border-l-muted">
|
||||
<CardContent className="p-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="mt-2 h-8 w-12" />
|
||||
<Skeleton className="mt-1 h-3 w-20" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Two-column content skeleton */}
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
<div className="space-y-6 lg:col-span-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</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-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader><Skeleton className="h-5 w-32" /></CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, j) => (
|
||||
<Skeleton key={j} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom skeleton */}
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
<Skeleton className="h-[400px] w-full rounded-lg lg:col-span-8" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg lg:col-span-4" />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
216
src/components/dashboard/pipeline-round-node.tsx
Normal file
216
src/components/dashboard/pipeline-round-node.tsx
Normal file
@@ -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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 + index * 0.06 }}
|
||||
>
|
||||
<Link
|
||||
href={`/admin/rounds/${round.id}` as Route}
|
||||
className="group block"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex flex-col items-center rounded-xl border-2 p-3 transition-all hover:-translate-y-0.5 hover:shadow-md',
|
||||
isActive ? 'w-44' : 'w-36',
|
||||
status.container
|
||||
)}
|
||||
>
|
||||
{/* Active ping indicator */}
|
||||
{isActive && (
|
||||
<span className="absolute -right-1 -top-1 flex h-3 w-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-blue-500" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||
typeConfig.iconBg
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-4 w-4', typeConfig.iconColor)} />
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<p className="mt-2 text-center text-xs font-semibold leading-tight line-clamp-2 group-hover:text-foreground transition-colors">
|
||||
{round.name}
|
||||
</p>
|
||||
|
||||
{/* Status label */}
|
||||
<span className="mt-1.5 text-[10px] font-medium uppercase tracking-wider opacity-70">
|
||||
{status.label}
|
||||
</span>
|
||||
|
||||
{/* Metric */}
|
||||
<p className="mt-1 text-[11px] font-medium tabular-nums opacity-80">
|
||||
{metric}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { PipelineRound }
|
||||
114
src/components/dashboard/project-list-compact.tsx
Normal file
114
src/components/dashboard/project-list-compact.tsx
Normal file
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10">
|
||||
<ClipboardList className="h-4 w-4 text-brand-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">Recent Projects</CardTitle>
|
||||
<CardDescription className="text-xs">Latest submissions</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/projects"
|
||||
className="flex items-center gap-1 text-xs font-semibold uppercase tracking-wider text-brand-teal hover:text-brand-teal-light transition-colors"
|
||||
>
|
||||
All projects <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<ClipboardList className="h-7 w-7 text-muted-foreground/40" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-muted-foreground">
|
||||
No projects submitted yet
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{projects.map((project, idx) => (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 + idx * 0.04 }}
|
||||
>
|
||||
<Link
|
||||
href={`/admin/projects/${project.id}`}
|
||||
className="block"
|
||||
>
|
||||
<div className="flex items-center gap-3 py-3 px-1 transition-colors hover:bg-muted/40 rounded-lg group">
|
||||
<ProjectLogo
|
||||
project={project}
|
||||
size="sm"
|
||||
fallback="initials"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
|
||||
{truncate(project.title, 50)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
119
src/components/dashboard/round-stats-evaluation.tsx
Normal file
119
src/components/dashboard/round-stats-evaluation.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{round.name} — Evaluation
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Assignments</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{assignmentCount}</p>
|
||||
<p className="mt-0.5 text-xs text-brand-blue-light">
|
||||
Jury-project pairs
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
|
||||
<ClipboardList className="h-5 w-5 text-brand-blue" />
|
||||
</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-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Submitted</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">
|
||||
{evalSubmitted}
|
||||
<span className="text-sm font-normal text-muted-foreground">/{evalTotal}</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-emerald-600">
|
||||
{completionPct}% complete
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">In Draft</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{evalDraft}</p>
|
||||
<p className="mt-0.5 text-xs text-amber-600">
|
||||
{evalDraft > 0 ? 'Not yet submitted' : 'No drafts'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
|
||||
<FileEdit className="h-5 w-5 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={3}>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Active Jurors</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{activeJurors}</p>
|
||||
<p className="mt-0.5 text-xs text-violet-600">
|
||||
Evaluating this round
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-violet-500/10">
|
||||
<Users className="h-5 w-5 text-violet-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
127
src/components/dashboard/round-stats-filtering.tsx
Normal file
127
src/components/dashboard/round-stats-filtering.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{round.name} — Filtering
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Projects to Filter</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.total}</p>
|
||||
<p className="mt-0.5 text-xs text-brand-blue-light">
|
||||
In pipeline
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
|
||||
<Filter className="h-5 w-5 text-brand-blue" />
|
||||
</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-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">AI Passed</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{filteringPassed}</p>
|
||||
<p className="mt-0.5 text-xs text-emerald-600">
|
||||
{projectStates.total > 0
|
||||
? `${((filteringPassed / projectStates.total) * 100).toFixed(0)}% pass rate`
|
||||
: 'No results yet'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-red-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">AI Rejected</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{filteringRejected}</p>
|
||||
<p className="mt-0.5 text-xs text-red-600">
|
||||
{projectStates.total > 0
|
||||
? `${((filteringRejected / projectStates.total) * 100).toFixed(0)}% rejected`
|
||||
: 'No results yet'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-500/10">
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={3}>
|
||||
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Flagged for Review</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{filteringFlagged}</p>
|
||||
<p className="mt-0.5 text-xs text-amber-600">
|
||||
{filteringFlagged > 0 ? 'Needs manual review' : 'None flagged'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
121
src/components/dashboard/round-stats-generic.tsx
Normal file
121
src/components/dashboard/round-stats-generic.tsx
Normal file
@@ -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 (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Projects</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{projectCount}</p>
|
||||
<p className="mt-0.5 text-xs text-brand-blue-light">
|
||||
{newProjectsThisWeek > 0 ? `+${newProjectsThisWeek} this week` : 'In edition'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
|
||||
<ClipboardList className="h-5 w-5 text-brand-blue" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Jury</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{totalJurors}</p>
|
||||
<p className="mt-0.5 text-xs text-violet-600">
|
||||
{activeJurors} active
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-violet-500/10">
|
||||
<Users className="h-5 w-5 text-violet-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Evaluations</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">
|
||||
{submittedCount}
|
||||
<span className="text-sm font-normal text-muted-foreground">/{totalAssignments}</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-emerald-600">
|
||||
{completionPct}% complete
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={3}>
|
||||
<Card className={`border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md ${actionsCount > 0 ? 'border-l-amber-500' : 'border-l-emerald-400'}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Actions Needed</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{actionsCount}</p>
|
||||
<p className={`mt-0.5 text-xs ${actionsCount > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
|
||||
{actionsCount > 0 ? 'Pending actions' : 'All clear'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${actionsCount > 0 ? 'bg-amber-500/10' : 'bg-emerald-400/10'}`}>
|
||||
{actionsCount > 0
|
||||
? <AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
: <CheckCircle2 className="h-5 w-5 text-emerald-400" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
src/components/dashboard/round-stats-intake.tsx
Normal file
122
src/components/dashboard/round-stats-intake.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{round.name} — Intake
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Projects</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.total}</p>
|
||||
<p className="mt-0.5 text-xs text-brand-blue-light">
|
||||
In this round
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
|
||||
<ClipboardList className="h-5 w-5 text-brand-blue" />
|
||||
</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-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Documents Complete</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.PASSED}</p>
|
||||
<p className="mt-0.5 text-xs text-emerald-600">
|
||||
{projectStates.total > 0
|
||||
? `${((projectStates.PASSED / projectStates.total) * 100).toFixed(0)}% of total`
|
||||
: 'No projects yet'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Pending Review</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.PENDING}</p>
|
||||
<p className="mt-0.5 text-xs text-amber-600">
|
||||
{projectStates.PENDING > 0 ? 'Awaiting review' : 'All reviewed'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
|
||||
<Clock className="h-5 w-5 text-amber-500" />
|
||||
</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-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">New This Week</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{newProjectsThisWeek}</p>
|
||||
<p className="mt-0.5 text-xs text-brand-teal">
|
||||
{newProjectsThisWeek > 0 ? 'Recently submitted' : 'No new submissions'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-teal/10">
|
||||
<TrendingUp className="h-5 w-5 text-brand-teal" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
src/components/dashboard/round-stats.tsx
Normal file
105
src/components/dashboard/round-stats.tsx
Normal file
@@ -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 (
|
||||
<RoundStatsGeneric
|
||||
projectCount={projectCount}
|
||||
newProjectsThisWeek={newProjectsThisWeek}
|
||||
totalJurors={totalJurors}
|
||||
activeJurors={activeJurors}
|
||||
totalAssignments={totalAssignments}
|
||||
evaluationStats={evaluationStats}
|
||||
actionsCount={actionsCount}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
switch (activeRound.roundType) {
|
||||
case 'INTAKE':
|
||||
return (
|
||||
<RoundStatsIntake
|
||||
round={activeRound}
|
||||
newProjectsThisWeek={newProjectsThisWeek}
|
||||
/>
|
||||
)
|
||||
case 'FILTERING':
|
||||
return (
|
||||
<RoundStatsFiltering round={activeRound} />
|
||||
)
|
||||
case 'EVALUATION':
|
||||
return (
|
||||
<RoundStatsEvaluation
|
||||
round={activeRound}
|
||||
activeJurors={activeJurors}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<RoundStatsGeneric
|
||||
projectCount={projectCount}
|
||||
newProjectsThisWeek={newProjectsThisWeek}
|
||||
totalJurors={totalJurors}
|
||||
activeJurors={activeJurors}
|
||||
totalAssignments={totalAssignments}
|
||||
evaluationStats={evaluationStats}
|
||||
actionsCount={actionsCount}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
119
src/components/dashboard/smart-actions.tsx
Normal file
119
src/components/dashboard/smart-actions.tsx
Normal file
@@ -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<DashboardAction['severity'], number> = {
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/40">
|
||||
<Zap className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<CardTitle className="flex-1">Action Required</CardTitle>
|
||||
{actions.length > 0 && (
|
||||
<Badge variant="warning">{actions.length}</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sorted.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/40">
|
||||
<CheckCircle2 className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-muted-foreground">
|
||||
All caught up!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sorted.map((action) => {
|
||||
const config = severityConfig[action.severity]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={action.id}
|
||||
href={action.href as Route}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border-l-2 px-3 py-2.5 transition-colors hover:opacity-80',
|
||||
config.bgClass,
|
||||
config.borderClass
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-4 w-4 shrink-0', config.iconClass)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium leading-tight">
|
||||
{action.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
175
src/components/dashboard/utils.ts
Normal file
175
src/components/dashboard/utils.ts
Normal file
@@ -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<string, string> = {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
29
src/instrumentation.ts
Normal file
29
src/instrumentation.ts
Normal file
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
@@ -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<string, ProjectStateCounts>()
|
||||
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<ProjectStateCounts, 'total'>] = row._count
|
||||
counts.total += row._count
|
||||
}
|
||||
}
|
||||
|
||||
// Build eval map: roundId -> { submitted, draft, total }
|
||||
const evalMap = new Map<string, { submitted: number; draft: number; total: number }>()
|
||||
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<string, { passed: number; rejected: number; flagged: number; total: number }>()
|
||||
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<string, string>()
|
||||
for (const s of liveSessions) {
|
||||
if (s.roundId) liveMap.set(s.roundId, s.status)
|
||||
}
|
||||
|
||||
// Build deliberation map: roundId -> count
|
||||
const delibMap = new Map<string, number>()
|
||||
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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user