Overhaul applicant portal: timeline, evaluations, nav, resources
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m6s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m6s
- Fix programId/competitionId bug in competition timeline - Add applicantVisibility config to EvaluationConfigSchema (JSONB) - Add admin UI card for controlling applicant feedback visibility - Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline, getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness, and extend getMyDashboard with hasPassedIntake - Rewrite competition timeline to show only EVALUATION + Grand Finale, synthesize FILTERING rejections, handle manually-created projects - Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources - Dashboard: conditional timeline, jury feedback card, deadlines, document completeness, conditional mentor tile - New /applicant/evaluations page with anonymous jury feedback - New /applicant/resources pages (clone of jury learning hub) - Rename /applicant/competitions → /applicant/competition - Remove broken /applicant/competitions/[windowId] page - Add permission info banner to team invite dialog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,36 +4,17 @@ 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 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { CheckCircle2, Circle, Clock, XCircle, Trophy } from 'lucide-react'
|
||||
|
||||
interface ApplicantCompetitionTimelineProps {
|
||||
competitionId: string
|
||||
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' },
|
||||
}
|
||||
|
||||
const statusIcons: Record<string, React.ElementType> = {
|
||||
completed: CheckCircle2,
|
||||
current: Clock,
|
||||
upcoming: Circle,
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
completed: 'text-emerald-600',
|
||||
current: 'text-brand-blue',
|
||||
upcoming: 'text-muted-foreground',
|
||||
}
|
||||
|
||||
const statusBgColors: Record<string, string> = {
|
||||
completed: 'bg-emerald-50',
|
||||
current: 'bg-brand-blue/10',
|
||||
upcoming: 'bg-muted',
|
||||
}
|
||||
|
||||
export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompetitionTimelineProps) {
|
||||
const { data: competition, isLoading } = trpc.competition.getById.useQuery(
|
||||
{ id: competitionId },
|
||||
{ enabled: !!competitionId }
|
||||
)
|
||||
export function ApplicantCompetitionTimeline() {
|
||||
const { data, isLoading } = trpc.applicant.getMyCompetitionTimeline.useQuery()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -52,7 +33,7 @@ export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompeti
|
||||
)
|
||||
}
|
||||
|
||||
if (!competition || !competition.rounds || competition.rounds.length === 0) {
|
||||
if (!data || data.entries.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -60,77 +41,117 @@ export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompeti
|
||||
</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</p>
|
||||
<p className="text-sm text-muted-foreground">No rounds available yet</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const rounds = competition.rounds || []
|
||||
const currentRoundIndex = rounds.findIndex(r => r.status === 'ROUND_ACTIVE')
|
||||
|
||||
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" />
|
||||
|
||||
{rounds.map((round, index) => {
|
||||
const isActive = round.status === 'ROUND_ACTIVE'
|
||||
const isCompleted = index < currentRoundIndex || round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED'
|
||||
const isCurrent = index === currentRoundIndex || isActive
|
||||
const status = isCompleted ? 'completed' : isCurrent ? 'current' : 'upcoming'
|
||||
const Icon = statusIcons[status]
|
||||
{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={round.id} className="relative flex items-start gap-4">
|
||||
<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 ${statusBgColors[status]} shrink-0`}
|
||||
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full ${iconBg} shrink-0`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 ${statusColors[status]}`} />
|
||||
<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">{round.name}</h3>
|
||||
<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>
|
||||
<Badge
|
||||
variant={
|
||||
status === 'completed'
|
||||
? 'default'
|
||||
: status === 'current'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
className={
|
||||
status === 'completed'
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: status === 'current'
|
||||
? 'bg-brand-blue text-white'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{status === 'completed' && 'Completed'}
|
||||
{status === 'current' && 'In Progress'}
|
||||
{status === 'upcoming' && 'Upcoming'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{round.windowOpenAt && round.windowCloseAt && (
|
||||
{entry.windowOpenAt && entry.windowCloseAt && (
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>
|
||||
Opens: {new Date(round.windowOpenAt).toLocaleDateString()}
|
||||
</p>
|
||||
<p>
|
||||
Closes: {new Date(round.windowCloseAt).toLocaleDateString()}
|
||||
</p>
|
||||
<p>Opens: {new Date(entry.windowOpenAt).toLocaleDateString()}</p>
|
||||
<p>Closes: {new Date(entry.windowCloseAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,3 +163,76 @@ export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompeti
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user