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

@@ -25,6 +25,14 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
update('advancementConfig', { ...advancementConfig, [key]: value })
}
const visConfig = (config.applicantVisibility as {
enabled?: boolean; showGlobalScore?: boolean; showCriterionScores?: boolean; showFeedbackText?: boolean
}) ?? {}
const updateVisibility = (key: string, value: unknown) => {
update('applicantVisibility', { ...visConfig, [key]: value })
}
return (
<div className="space-y-6">
{/* Scoring */}
@@ -202,6 +210,71 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
</CardContent>
</Card>
{/* Applicant Feedback Visibility */}
<Card>
<CardHeader>
<CardTitle className="text-base">Applicant Feedback Visibility</CardTitle>
<CardDescription>Control what evaluation data applicants can see after this round closes</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="applicantVisEnabled">Show Evaluations to Applicants</Label>
<p className="text-xs text-muted-foreground">Master switch when off, nothing is visible to applicants</p>
</div>
<Switch
id="applicantVisEnabled"
checked={visConfig.enabled ?? false}
onCheckedChange={(v) => updateVisibility('enabled', v)}
/>
</div>
{visConfig.enabled && (
<div className="pl-6 border-l-2 border-muted space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showGlobalScore">Show Global Score</Label>
<p className="text-xs text-muted-foreground">Display the overall score for each evaluation</p>
</div>
<Switch
id="showGlobalScore"
checked={visConfig.showGlobalScore ?? false}
onCheckedChange={(v) => updateVisibility('showGlobalScore', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showCriterionScores">Show Per-Criterion Scores</Label>
<p className="text-xs text-muted-foreground">Display individual criterion scores and names</p>
</div>
<Switch
id="showCriterionScores"
checked={visConfig.showCriterionScores ?? false}
onCheckedChange={(v) => updateVisibility('showCriterionScores', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showFeedbackText">Show Written Feedback</Label>
<p className="text-xs text-muted-foreground">Display jury members&apos; written comments</p>
</div>
<Switch
id="showFeedbackText"
checked={visConfig.showFeedbackText ?? false}
onCheckedChange={(v) => updateVisibility('showFeedbackText', v)}
/>
</div>
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
Evaluations are only visible to applicants after this round closes.
</p>
</div>
)}
</CardContent>
</Card>
{/* Advancement */}
<Card>
<CardHeader>

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

View File

@@ -1,6 +1,7 @@
'use client'
import { Home, Users, FileText, MessageSquare, Layers } from 'lucide-react'
import { Home, Users, FileText, MessageSquare, Trophy, Star, BookOpen } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface ApplicantNavProps {
@@ -8,32 +9,22 @@ interface ApplicantNavProps {
}
export function ApplicantNav({ user }: ApplicantNavProps) {
const { data: flags } = trpc.applicant.getNavFlags.useQuery(undefined, {
staleTime: 60_000,
})
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/applicant',
icon: Home,
},
{
name: 'Team',
href: '/applicant/team',
icon: Users,
},
{
name: 'Competitions',
href: '/applicant/competitions',
icon: Layers,
},
{
name: 'Documents',
href: '/applicant/documents',
icon: FileText,
},
{
name: 'Mentoring',
href: '/applicant/mentor',
icon: MessageSquare,
},
{ name: 'Dashboard', href: '/applicant', icon: Home },
{ name: 'Team', href: '/applicant/team', icon: Users },
{ name: 'Competition', href: '/applicant/competition', icon: Trophy },
{ name: 'Documents', href: '/applicant/documents', icon: FileText },
...(flags?.hasEvaluationRounds
? [{ name: 'Evaluations', href: '/applicant/evaluations', icon: Star }]
: []),
...(flags?.hasMentor
? [{ name: 'Mentoring', href: '/applicant/mentor', icon: MessageSquare }]
: []),
{ name: 'Resources', href: '/applicant/resources', icon: BookOpen },
]
return (