Batch 1 - Quick Wins: - F1: Evaluation progress indicator with touch tracking in sticky status bar - F2: Export filtering results as CSV with dynamic AI column flattening - F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure) Batch 2 - Jury Experience: - F4: Countdown timer component with urgency colors + email reminder service with cron endpoint - F5: Conflict of interest declaration system (dialog, admin management, review workflow) Batch 3 - Admin & AI Enhancements: - F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording - F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns - F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking) Batch 4 - Form Flexibility & Applicant Portal: - F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility) - F10: Applicant portal (status timeline, per-round documents, mentor messaging) Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
187 lines
6.1 KiB
TypeScript
187 lines
6.1 KiB
TypeScript
'use client'
|
|
|
|
import { cn } from '@/lib/utils'
|
|
import { CheckCircle, Circle, Clock, XCircle, Trophy } from 'lucide-react'
|
|
|
|
interface TimelineItem {
|
|
status: string
|
|
label: string
|
|
date: Date | string | null
|
|
completed: boolean
|
|
isTerminal?: boolean
|
|
}
|
|
|
|
interface StatusTrackerProps {
|
|
timeline: TimelineItem[]
|
|
currentStatus: string
|
|
className?: string
|
|
}
|
|
|
|
export function StatusTracker({
|
|
timeline,
|
|
currentStatus,
|
|
className,
|
|
}: StatusTrackerProps) {
|
|
const formatDate = (date: Date | string | null) => {
|
|
if (!date) return null
|
|
const d = typeof date === 'string' ? new Date(date) : date
|
|
return d.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className={cn('relative', className)}>
|
|
<div className="space-y-0">
|
|
{timeline.map((item, index) => {
|
|
const isCompleted = item.completed
|
|
const isCurrent =
|
|
isCompleted && !timeline[index + 1]?.completed
|
|
const isPending = !isCompleted
|
|
const isRejected = item.status === 'REJECTED' && item.isTerminal
|
|
const isWinner = item.status === 'WINNER' && isCompleted
|
|
|
|
return (
|
|
<div key={item.status} className="relative flex gap-4">
|
|
{/* Vertical line */}
|
|
{index < timeline.length - 1 && (
|
|
<div
|
|
className={cn(
|
|
'absolute left-[15px] top-[32px] h-full w-0.5',
|
|
isRejected
|
|
? 'bg-destructive/30'
|
|
: isCompleted
|
|
? 'bg-primary'
|
|
: 'bg-muted'
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* Icon */}
|
|
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center">
|
|
{isRejected ? (
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground">
|
|
<XCircle className="h-4 w-4" />
|
|
</div>
|
|
) : isWinner ? (
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-yellow-500 text-white">
|
|
<Trophy className="h-4 w-4" />
|
|
</div>
|
|
) : isCompleted ? (
|
|
<div
|
|
className={cn(
|
|
'flex h-8 w-8 items-center justify-center rounded-full',
|
|
isCurrent
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-primary/20 text-primary'
|
|
)}
|
|
>
|
|
{isCurrent ? (
|
|
<Clock className="h-4 w-4" />
|
|
) : (
|
|
<CheckCircle className="h-4 w-4" />
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-muted bg-background">
|
|
<Circle className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 pb-8">
|
|
<div className="flex items-center gap-2">
|
|
<p
|
|
className={cn(
|
|
'font-medium',
|
|
isRejected && 'text-destructive',
|
|
isWinner && 'text-yellow-600',
|
|
isPending && !isRejected && 'text-muted-foreground'
|
|
)}
|
|
>
|
|
{item.label}
|
|
</p>
|
|
{isCurrent && !isRejected && !isWinner && (
|
|
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
|
|
Current
|
|
</span>
|
|
)}
|
|
{isRejected && (
|
|
<span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded-full">
|
|
Final
|
|
</span>
|
|
)}
|
|
{isWinner && (
|
|
<span className="text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full">
|
|
Winner
|
|
</span>
|
|
)}
|
|
</div>
|
|
{item.date && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{formatDate(item.date)}
|
|
</p>
|
|
)}
|
|
{isPending && !isCurrent && !isRejected && (
|
|
<p className="text-sm text-muted-foreground">Pending</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Compact horizontal version
|
|
interface StatusBarProps {
|
|
status: string
|
|
statuses: { value: string; label: string }[]
|
|
className?: string
|
|
}
|
|
|
|
export function StatusBar({ status, statuses, className }: StatusBarProps) {
|
|
const currentIndex = statuses.findIndex((s) => s.value === status)
|
|
|
|
return (
|
|
<div className={cn('flex items-center gap-2', className)}>
|
|
{statuses.map((s, index) => {
|
|
const isCompleted = index <= currentIndex
|
|
const isCurrent = index === currentIndex
|
|
|
|
return (
|
|
<div key={s.value} className="flex items-center gap-2">
|
|
{index > 0 && (
|
|
<div
|
|
className={cn(
|
|
'h-0.5 w-8',
|
|
isCompleted ? 'bg-primary' : 'bg-muted'
|
|
)}
|
|
/>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium',
|
|
isCurrent
|
|
? 'bg-primary text-primary-foreground'
|
|
: isCompleted
|
|
? 'bg-primary/20 text-primary'
|
|
: 'bg-muted text-muted-foreground'
|
|
)}
|
|
>
|
|
{isCompleted && !isCurrent && (
|
|
<CheckCircle className="h-3 w-3" />
|
|
)}
|
|
{s.label}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|