Overhaul applicant portal: timeline, evaluations, nav, resources
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:
2026-02-21 19:51:17 +01:00
parent ee2f10e080
commit 5a609457c2
13 changed files with 1291 additions and 314 deletions

View File

@@ -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>
)
}