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 { z } from 'zod'
|
||||||
import { router, adminProcedure } from '../trpc'
|
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({
|
export const dashboardRouter = router({
|
||||||
/**
|
/**
|
||||||
* Get all dashboard stats in a single query batch.
|
* Get all dashboard stats in a single query batch.
|
||||||
* Replaces the 16 parallel Prisma queries that were previously
|
* Returns pipeline rounds, smart actions, and supporting data.
|
||||||
* run during SSR, which blocked the event loop and caused 503s.
|
|
||||||
*/
|
*/
|
||||||
getStats: adminProcedure
|
getStats: adminProcedure
|
||||||
.input(z.object({ editionId: z.string() }))
|
.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)
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
// ── All queries in parallel ──────────────────────────────────────
|
||||||
|
|
||||||
const [
|
const [
|
||||||
activeRoundCount,
|
// Pipeline rounds (all, ordered by sortOrder)
|
||||||
totalRoundCount,
|
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,
|
projectCount,
|
||||||
newProjectsThisWeek,
|
newProjectsThisWeek,
|
||||||
totalJurors,
|
totalJurors,
|
||||||
activeJurors,
|
activeJurors,
|
||||||
evaluationStats,
|
evaluationStats,
|
||||||
totalAssignments,
|
totalAssignments,
|
||||||
recentRounds,
|
// Lists
|
||||||
latestProjects,
|
latestProjects,
|
||||||
categoryBreakdown,
|
categoryBreakdown,
|
||||||
oceanIssueBreakdown,
|
oceanIssueBreakdown,
|
||||||
recentActivity,
|
recentActivity,
|
||||||
|
// Action signals
|
||||||
pendingCOIs,
|
pendingCOIs,
|
||||||
draftRounds,
|
|
||||||
unassignedProjects,
|
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
ctx.prisma.round.count({
|
// 1. All pipeline rounds
|
||||||
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 } } },
|
|
||||||
}),
|
|
||||||
ctx.prisma.round.findMany({
|
ctx.prisma.round.findMany({
|
||||||
where: { competition: { programId: editionId } },
|
where: { competition: { programId: editionId } },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
take: 5,
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
status: true,
|
slug: true,
|
||||||
roundType: true,
|
roundType: true,
|
||||||
|
status: true,
|
||||||
|
sortOrder: true,
|
||||||
windowOpenAt: true,
|
windowOpenAt: true,
|
||||||
windowCloseAt: true,
|
windowCloseAt: true,
|
||||||
_count: {
|
_count: {
|
||||||
@@ -93,13 +131,86 @@ export const dashboardRouter = router({
|
|||||||
assignments: true,
|
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({
|
ctx.prisma.project.findMany({
|
||||||
where: { programId: editionId },
|
where: { programId: editionId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -117,20 +228,24 @@ export const dashboardRouter = router({
|
|||||||
status: true,
|
status: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 14. Category breakdown
|
||||||
ctx.prisma.project.groupBy({
|
ctx.prisma.project.groupBy({
|
||||||
by: ['competitionCategory'],
|
by: ['competitionCategory'],
|
||||||
where: { programId: editionId },
|
where: { programId: editionId },
|
||||||
_count: true,
|
_count: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 15. Ocean issue breakdown
|
||||||
ctx.prisma.project.groupBy({
|
ctx.prisma.project.groupBy({
|
||||||
by: ['oceanIssue'],
|
by: ['oceanIssue'],
|
||||||
where: { programId: editionId },
|
where: { programId: editionId },
|
||||||
_count: true,
|
_count: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 16. Recent activity
|
||||||
ctx.prisma.auditLog.findMany({
|
ctx.prisma.auditLog.findMany({
|
||||||
where: {
|
where: { timestamp: { gte: sevenDaysAgo } },
|
||||||
timestamp: { gte: sevenDaysAgo },
|
|
||||||
},
|
|
||||||
orderBy: { timestamp: 'desc' },
|
orderBy: { timestamp: 'desc' },
|
||||||
take: 8,
|
take: 8,
|
||||||
select: {
|
select: {
|
||||||
@@ -141,6 +256,8 @@ export const dashboardRouter = router({
|
|||||||
user: { select: { name: true } },
|
user: { select: { name: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 17. Pending COIs
|
||||||
ctx.prisma.conflictOfInterest.count({
|
ctx.prisma.conflictOfInterest.count({
|
||||||
where: {
|
where: {
|
||||||
hasConflict: true,
|
hasConflict: true,
|
||||||
@@ -148,40 +265,204 @@ export const dashboardRouter = router({
|
|||||||
assignment: { round: { competition: { programId: editionId } } },
|
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 {
|
return {
|
||||||
edition,
|
edition,
|
||||||
activeRoundCount,
|
// Pipeline
|
||||||
totalRoundCount,
|
pipelineRounds,
|
||||||
|
activeRoundId,
|
||||||
|
// Smart actions
|
||||||
|
nextActions,
|
||||||
|
// Summary counts
|
||||||
projectCount,
|
projectCount,
|
||||||
newProjectsThisWeek,
|
newProjectsThisWeek,
|
||||||
totalJurors,
|
totalJurors,
|
||||||
activeJurors,
|
activeJurors,
|
||||||
evaluationStats,
|
evaluationStats,
|
||||||
totalAssignments,
|
totalAssignments,
|
||||||
recentRounds,
|
pendingCOIs,
|
||||||
|
// Lists
|
||||||
latestProjects,
|
latestProjects,
|
||||||
categoryBreakdown,
|
categoryBreakdown,
|
||||||
oceanIssueBreakdown,
|
oceanIssueBreakdown,
|
||||||
recentActivity,
|
recentActivity,
|
||||||
pendingCOIs,
|
|
||||||
draftRounds,
|
|
||||||
unassignedProjects,
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user