Files
MOPC-Portal/src/components/applicant/competition-timeline.tsx

239 lines
8.4 KiB
TypeScript
Raw Normal View History

'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { CheckCircle2, Circle, Clock, XCircle, Trophy } from 'lucide-react'
const roundStatusDisplay: Record<string, { label: string; variant: 'default' | 'secondary' }> = {
ROUND_DRAFT: { label: 'Upcoming', variant: 'secondary' },
ROUND_ACTIVE: { label: 'In Progress', variant: 'default' },
ROUND_CLOSED: { label: 'Completed', variant: 'default' },
ROUND_ARCHIVED: { label: 'Completed', variant: 'default' },
}
export function ApplicantCompetitionTimeline() {
const { data, isLoading } = trpc.applicant.getMyCompetitionTimeline.useQuery()
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20" />
))}
</div>
</CardContent>
</Card>
)
}
if (!data || data.entries.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Competition Timeline</CardTitle>
</CardHeader>
<CardContent className="text-center py-8">
<Circle className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No rounds available yet</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Competition Timeline</CardTitle>
{data.competitionName && (
<p className="text-sm text-muted-foreground">{data.competitionName}</p>
)}
</CardHeader>
<CardContent>
<div className="relative space-y-6">
{/* Vertical connecting line */}
<div className="absolute left-5 top-5 bottom-5 w-0.5 bg-border" />
{data.entries.map((entry) => {
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
const isActive = entry.status === 'ROUND_ACTIVE'
const isRejected = entry.projectState === 'REJECTED'
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
// Determine icon
let Icon = Circle
let iconBg = 'bg-muted'
let iconColor = 'text-muted-foreground'
if (isRejected) {
Icon = XCircle
iconBg = 'bg-red-50'
iconColor = 'text-red-600'
} else if (isGrandFinale && isCompleted) {
Icon = Trophy
iconBg = 'bg-yellow-50'
iconColor = 'text-yellow-600'
} else if (isCompleted) {
Icon = CheckCircle2
iconBg = 'bg-emerald-50'
iconColor = 'text-emerald-600'
} else if (isActive) {
Icon = Clock
iconBg = 'bg-brand-blue/10'
iconColor = 'text-brand-blue'
}
// Project state display
let stateLabel: string | null = null
if (entry.projectState === 'REJECTED') {
stateLabel = 'Not Selected'
} else if (entry.projectState === 'PASSED' || entry.projectState === 'COMPLETED') {
stateLabel = 'Advanced'
} else if (entry.projectState === 'IN_PROGRESS') {
stateLabel = 'Under Review'
} else if (entry.projectState === 'PENDING') {
stateLabel = 'Pending'
}
const statusInfo = roundStatusDisplay[entry.status] ?? { label: 'Upcoming', variant: 'secondary' as const }
return (
<div key={entry.id} className="relative flex items-start gap-4">
{/* Icon */}
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full ${iconBg} shrink-0`}
>
<Icon className={`h-5 w-5 ${iconColor}`} />
</div>
{/* Content */}
<div className="flex-1 min-w-0 pb-6">
<div className="flex items-start justify-between flex-wrap gap-2 mb-2">
<div>
<h3 className="font-semibold">{entry.label}</h3>
</div>
<div className="flex items-center gap-2">
{stateLabel && (
<Badge
variant="outline"
className={
isRejected
? 'border-red-200 text-red-700 bg-red-50'
: entry.projectState === 'PASSED' || entry.projectState === 'COMPLETED'
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
: ''
}
>
{stateLabel}
</Badge>
)}
<Badge
variant={statusInfo.variant}
className={
isCompleted
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: isActive
? 'bg-brand-blue text-white'
: ''
}
>
{statusInfo.label}
</Badge>
</div>
</div>
{entry.windowOpenAt && entry.windowCloseAt && (
<div className="text-sm text-muted-foreground space-y-1">
<p>Opens: {new Date(entry.windowOpenAt).toLocaleDateString()}</p>
<p>Closes: {new Date(entry.windowCloseAt).toLocaleDateString()}</p>
</div>
)}
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)
}
/**
* Compact sidebar variant for the dashboard.
* Shows dots + labels, no date details.
*/
export function CompetitionTimelineSidebar() {
const { data, isLoading } = trpc.applicant.getMyCompetitionTimeline.useQuery()
if (isLoading) {
return (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-6" />
))}
</div>
)
}
if (!data || data.entries.length === 0) {
return <p className="text-sm text-muted-foreground">No rounds available</p>
}
return (
<div className="space-y-0">
{data.entries.map((entry, index) => {
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
const isActive = entry.status === 'ROUND_ACTIVE'
const isRejected = entry.projectState === 'REJECTED'
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
const isLast = index === data.entries.length - 1
let dotColor = 'border-2 border-muted bg-background'
if (isRejected) dotColor = 'bg-destructive'
else if (isGrandFinale && isCompleted) dotColor = 'bg-yellow-500'
else if (isCompleted) dotColor = 'bg-primary'
else if (isActive) dotColor = 'bg-primary ring-2 ring-primary/30'
return (
<div key={entry.id} className="relative flex gap-3">
{/* Connecting line */}
{!isLast && (
<div className="absolute left-[7px] top-[20px] h-full w-0.5 bg-muted" />
)}
{/* Dot */}
<div className={`relative z-10 mt-1.5 h-4 w-4 rounded-full shrink-0 ${dotColor}`} />
{/* Label */}
<div className="flex-1 pb-4">
<p
className={`text-sm font-medium ${
isRejected
? 'text-destructive'
: isCompleted || isActive
? 'text-foreground'
: 'text-muted-foreground'
}`}
>
{entry.label}
</p>
{isRejected && (
<p className="text-xs text-destructive">Not Selected</p>
)}
{isActive && (
<p className="text-xs text-primary">In Progress</p>
)}
</div>
</div>
)
})}
</div>
)
}