Files
MOPC-Portal/src/components/dashboard/pipeline-round-node.tsx

219 lines
6.9 KiB
TypeScript
Raw Normal View History

'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { cn } from '@/lib/utils'
import { motion } from 'motion/react'
import {
roundTypeConfig as sharedRoundTypeConfig,
roundStatusConfig as sharedRoundStatusConfig,
} from '@/lib/round-config'
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 = sharedRoundTypeConfig
const statusStyles: Record<string, { container: string; label: string }> = Object.fromEntries(
Object.entries(sharedRoundStatusConfig).map(([k, v]) => [k, { container: v.pipelineContainer, label: v.label }])
)
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':
Admin UI audit round 2: fix 28 display bugs across 23 files HIGH fixes (broken features / wrong data): - H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences) - H2: Fix deliberation results panel blank table (wrong field names) - H3: Fix deliberation participant names blank (wrong data path) - H4: Fix awards "Evaluated" stat duplicating "Eligible" count - H5: Fix cross-round comparison enabled at 1 round (backend requires 2) - H6: Fix setState during render anti-pattern (6 occurrences) - H7: Fix round detail jury member count always showing 0 - H8: Remove 4 invalid status values from observer dashboard filter - H9: Fix filtering progress bar always showing 100% MEDIUM fixes (misleading display): - M1: Filter special-award rounds from competition timeline - M2: Exclude special-award rounds from distinct project count - M3: Fix MENTORING pipeline node hardcoded "0 mentored" - M4: Fix DELIB_LOCKED badge using red for success state - M5: Add status label maps to deliberation session detail - M6: Humanize deliberation category + tie-break method displays - M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round" - M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels - M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge - M11: Fix unsent messages showing "Scheduled" instead of "Draft" - M12: Rename misleading totalEvaluations → totalAssignments - M13: Rename "Stage" column to "Program" in projects page LOW fixes (cosmetic / edge-case): - L1: Use unfiltered rounds array for active round detection - L2: Use all rounds length for new round sort order - L3: Filter special-award rounds from header count - L4: Fix single-underscore replace in award status badges - L5: Fix score bucket boundary gaps (4.99 dropped between buckets) - L6: Title-case LIVE_FINAL pipeline metric status - L7: Fix roundType.replace only replacing first underscore - L8: Remove duplicate severity sort in smart-actions component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:11:00 +01:00
return `${projectStates.COMPLETED ?? 0} mentored`
case 'LIVE_FINAL': {
const status = liveSessionStatus
return status ? status.charAt(0) + status.slice(1).toLowerCase() : `${projectStates.total} finalists`
}
case 'DELIBERATION':
return deliberationCount > 0
? `${deliberationCount} sessions`
: 'Not started'
default:
return `${projectStates.total} projects`
}
}
function getProgressPct(round: PipelineRound): number | null {
if (round.status !== 'ROUND_ACTIVE') return null
switch (round.roundType) {
case 'FILTERING': {
const processed = round.filteringPassed + round.filteringRejected + round.filteringFlagged
const total = round.projectStates.total || round.filteringTotal
return total > 0 ? Math.round((processed / total) * 100) : 0
}
case 'EVALUATION':
return round.evalTotal > 0 ? Math.round((round.evalSubmitted / round.evalTotal) * 100) : 0
case 'SUBMISSION': {
const total = round.projectStates.total
return total > 0 ? Math.round((round.projectStates.COMPLETED / total) * 100) : 0
}
case 'MENTORING': {
const total = round.projectStates.total
return total > 0 ? Math.round((round.projectStates.COMPLETED / total) * 100) : 0
}
default:
return null
}
}
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 isCompleted = round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED'
const metric = getMetric(round)
const progressPct = getProgressPct(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 transition-all hover:-translate-y-0.5 hover:shadow-md',
isActive ? 'w-48 px-4 py-4' : 'w-40 px-3 py-3.5',
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>
)}
{/* Completed check */}
{isCompleted && (
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500 text-white">
<svg className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</span>
)}
{/* Icon */}
<div
className={cn(
'flex items-center justify-center rounded-lg',
isActive ? 'h-10 w-10' : 'h-9 w-9',
typeConfig.iconBg
)}
>
<Icon className={cn(isActive ? 'h-5 w-5' : 'h-4 w-4', typeConfig.iconColor)} />
</div>
{/* Name */}
<p className="mt-2.5 text-center text-xs font-semibold leading-tight line-clamp-2 group-hover:text-foreground transition-colors">
{round.name}
</p>
{/* Type label */}
<span className="mt-1 text-[10px] font-medium text-muted-foreground/70">
{typeConfig.label}
</span>
{/* Status label */}
<span className="mt-1 text-[10px] font-semibold uppercase tracking-wider opacity-70">
{status.label}
</span>
{/* Progress bar for active rounds */}
{progressPct !== null && (
<div className="mt-2 w-full">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-black/5">
<motion.div
className="h-full rounded-full bg-blue-500"
initial={{ width: 0 }}
animate={{ width: `${progressPct}%` }}
transition={{ duration: 0.8, ease: 'easeOut' }}
/>
</div>
<p className="mt-0.5 text-center text-[10px] font-medium tabular-nums text-blue-600">
{progressPct}%
</p>
</div>
)}
{/* Metric */}
{progressPct === null && (
<p className="mt-1.5 text-[11px] font-medium tabular-nums opacity-80">
{metric}
</p>
)}
</div>
</Link>
</motion.div>
)
}
export type { PipelineRound }