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:
@@ -40,12 +40,12 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/applicant/pipeline',
|
source: '/applicant/pipeline',
|
||||||
destination: '/applicant/competitions',
|
destination: '/applicant/competition',
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/applicant/pipeline/:path*',
|
source: '/applicant/pipeline/:path*',
|
||||||
destination: '/applicant/competitions',
|
destination: '/applicant/competition',
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import { useSession } from 'next-auth/react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
|
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
|
||||||
import { ArrowLeft, FileText, Calendar } from 'lucide-react'
|
import { ArrowLeft, FileText, Calendar } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
export default function ApplicantCompetitionsPage() {
|
export default function ApplicantCompetitionPage() {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
||||||
enabled: !!session,
|
enabled: !!session,
|
||||||
@@ -26,7 +25,7 @@ export default function ApplicantCompetitionsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const competitionId = myProject?.project?.programId
|
const hasProject = !!myProject?.project
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -45,7 +44,7 @@ export default function ApplicantCompetitionsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!competitionId ? (
|
{!hasProject ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||||
@@ -59,7 +58,7 @@ export default function ApplicantCompetitionsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<ApplicantCompetitionTimeline competitionId={competitionId} />
|
<ApplicantCompetitionTimeline />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
||||||
import { FileUploadSlot } from '@/components/applicant/file-upload-slot'
|
|
||||||
import { ArrowLeft, Lock, Clock, Calendar, AlertCircle } from 'lucide-react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
export default function ApplicantSubmissionWindowPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const windowId = params.windowId as string
|
|
||||||
|
|
||||||
const [uploadedFiles, setUploadedFiles] = useState<Record<string, File>>({})
|
|
||||||
|
|
||||||
const { data: window, isLoading } = trpc.round.getById.useQuery(
|
|
||||||
{ id: windowId },
|
|
||||||
{ enabled: !!windowId }
|
|
||||||
)
|
|
||||||
|
|
||||||
const { data: deadlineStatus } = trpc.round.checkDeadline.useQuery(
|
|
||||||
{ windowId },
|
|
||||||
{
|
|
||||||
enabled: !!windowId,
|
|
||||||
refetchInterval: 60000, // Refresh every minute
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleUpload = (requirementId: string, file: File) => {
|
|
||||||
setUploadedFiles(prev => ({ ...prev, [requirementId]: file }))
|
|
||||||
toast.success(`File "${file.name}" selected for upload`)
|
|
||||||
// In a real implementation, this would trigger file upload
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Skeleton className="h-8 w-64" />
|
|
||||||
<Skeleton className="h-96" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
|
||||||
<Link href={'/applicant/competitions' as Route}>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
|
||||||
<p className="font-medium">Submission window not found</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLocked = deadlineStatus?.status === 'LOCKED' || deadlineStatus?.status === 'CLOSED'
|
|
||||||
const deadline = window.windowCloseAt
|
|
||||||
? new Date(window.windowCloseAt)
|
|
||||||
: null
|
|
||||||
const timeRemaining = deadline ? deadline.getTime() - Date.now() : null
|
|
||||||
const daysRemaining = timeRemaining ? Math.floor(timeRemaining / (1000 * 60 * 60 * 24)) : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
|
||||||
<Link href={'/applicant/competitions' as Route}>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{window.name}</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Upload required documents for this submission window
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Deadline card */}
|
|
||||||
{deadline && (
|
|
||||||
<Card className={isLocked ? 'border-red-200 bg-red-50/50' : 'border-l-4 border-l-amber-500'}>
|
|
||||||
<CardContent className="flex items-start gap-3 p-4">
|
|
||||||
{isLocked ? (
|
|
||||||
<>
|
|
||||||
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-medium text-sm text-red-900">Submission Window Closed</p>
|
|
||||||
<p className="text-sm text-red-700 mt-1">
|
|
||||||
This submission window closed on {deadline.toLocaleDateString()}. No further
|
|
||||||
uploads are allowed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Clock className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-medium text-sm">Deadline Countdown</p>
|
|
||||||
<div className="flex items-baseline gap-2 mt-1">
|
|
||||||
<span className="text-2xl font-bold tabular-nums text-amber-600">
|
|
||||||
{daysRemaining !== null ? daysRemaining : '—'}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
day{daysRemaining !== 1 ? 's' : ''} remaining
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Due: {deadline.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File requirements */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>File Requirements</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Upload the required files below. {isLocked && 'Viewing only - window is closed.'}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* File requirements would be fetched separately in a real implementation */}
|
|
||||||
{false ? (
|
|
||||||
[].map((req: any) => (
|
|
||||||
<FileUploadSlot
|
|
||||||
key={req.id}
|
|
||||||
requirement={{
|
|
||||||
id: req.id,
|
|
||||||
label: req.label,
|
|
||||||
description: req.description,
|
|
||||||
mimeTypes: req.mimeTypes || [],
|
|
||||||
maxSizeMb: req.maxSizeMb,
|
|
||||||
required: req.required || false,
|
|
||||||
}}
|
|
||||||
isLocked={isLocked}
|
|
||||||
onUpload={(file) => handleUpload(req.id, file)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Calendar className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No file requirements defined for this window
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{!isLocked && (
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button variant="outline">Save Draft</Button>
|
|
||||||
<Button className="bg-brand-blue hover:bg-brand-blue-light">
|
|
||||||
Submit All Files
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
147
src/app/(applicant)/applicant/evaluations/page.tsx
Normal file
147
src/app/(applicant)/applicant/evaluations/page.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'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 { Star, MessageSquare } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function ApplicantEvaluationsPage() {
|
||||||
|
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
||||||
|
<p className="text-muted-foreground">Anonymous evaluations from jury members</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="py-6">
|
||||||
|
<Skeleton className="h-6 w-48 mb-4" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEvaluations = rounds && rounds.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Jury Feedback</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Anonymous evaluations from jury members
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasEvaluations ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Star className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">No Evaluations Available</h3>
|
||||||
|
<p className="text-muted-foreground text-center max-w-md">
|
||||||
|
Evaluations will appear here once jury review is complete and results are published.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{rounds.map((round) => (
|
||||||
|
<Card key={round.roundId}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>{round.roundName}</CardTitle>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{round.evaluationCount} evaluation{round.evaluationCount !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{round.evaluations.map((ev, idx) => (
|
||||||
|
<div
|
||||||
|
key={ev.id}
|
||||||
|
className="rounded-lg border p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
Evaluator #{idx + 1}
|
||||||
|
</span>
|
||||||
|
{ev.submittedAt && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(ev.submittedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ev.globalScore !== null && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Star className="h-4 w-4 text-yellow-500" />
|
||||||
|
<span className="text-lg font-semibold">{ev.globalScore}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">/ 100</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ev.criterionScores && ev.criteria && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Criterion Scores</p>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{(() => {
|
||||||
|
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
|
||||||
|
const scores = ev.criterionScores as Record<string, number>
|
||||||
|
return criteria
|
||||||
|
.filter((c) => c.id || c.label || c.name)
|
||||||
|
.map((c, ci) => {
|
||||||
|
const key = c.id || String(ci)
|
||||||
|
const score = scores[key]
|
||||||
|
return (
|
||||||
|
<div key={ci} className="flex items-center justify-between text-sm">
|
||||||
|
<span>{c.label || c.name || `Criterion ${ci + 1}`}</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{score !== undefined ? score : '—'}
|
||||||
|
{c.maxScore ? ` / ${c.maxScore}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ev.feedbackText && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
|
||||||
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
|
Written Feedback
|
||||||
|
</div>
|
||||||
|
<blockquote className="border-l-2 border-muted pl-4 text-sm italic text-muted-foreground">
|
||||||
|
{ev.feedbackText}
|
||||||
|
</blockquote>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
Evaluator identities are kept confidential.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,25 +9,26 @@ import { Button } from '@/components/ui/button'
|
|||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
import { StatusTracker } from '@/components/shared/status-tracker'
|
||||||
|
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
AlertCircle,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Users,
|
Users,
|
||||||
Crown,
|
Crown,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Upload,
|
Upload,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
Star,
|
||||||
|
AlertCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||||
@@ -49,6 +50,18 @@ export default function ApplicantDashboardPage() {
|
|||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: deadlines } = trpc.applicant.getUpcomingDeadlines.useQuery(undefined, {
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: docCompleteness } = trpc.applicant.getDocumentCompleteness.useQuery(undefined, {
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: evaluations } = trpc.applicant.getMyEvaluations.useQuery(undefined, {
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
})
|
||||||
|
|
||||||
if (sessionStatus === 'loading' || isLoading) {
|
if (sessionStatus === 'loading' || isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -98,10 +111,11 @@ export default function ApplicantDashboardPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { project, timeline, currentStatus, openRounds } = data
|
const { project, timeline, currentStatus, openRounds, hasPassedIntake } = data
|
||||||
const isDraft = !project.submittedAt
|
const isDraft = !project.submittedAt
|
||||||
const programYear = project.program?.year
|
const programYear = project.program?.year
|
||||||
const programName = project.program?.name
|
const programName = project.program?.name
|
||||||
|
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -213,7 +227,7 @@ export default function ApplicantDashboardPage() {
|
|||||||
|
|
||||||
{/* Quick actions */}
|
{/* Quick actions */}
|
||||||
<AnimatedCard index={1}>
|
<AnimatedCard index={1}>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Link href={"/applicant/documents" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
<Link href={"/applicant/documents" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
||||||
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20">
|
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20">
|
||||||
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
@@ -240,6 +254,7 @@ export default function ApplicantDashboardPage() {
|
|||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{project.mentorAssignment && (
|
||||||
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
|
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
|
||||||
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
|
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
|
||||||
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
@@ -247,34 +262,100 @@ export default function ApplicantDashboardPage() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium">Mentor</p>
|
<p className="text-sm font-medium">Mentor</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{project.mentorAssignment?.mentor?.name || 'Not assigned'}
|
{project.mentorAssignment.mentor?.name || 'Assigned'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
|
{/* Document Completeness */}
|
||||||
|
{docCompleteness && docCompleteness.length > 0 && (
|
||||||
|
<AnimatedCard index={2}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
Document Progress
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{docCompleteness.map((dc) => (
|
||||||
|
<div key={dc.roundId}>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1.5">
|
||||||
|
<span className="font-medium">{dc.roundName}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{dc.uploaded}/{dc.required} files
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${dc.required > 0 ? Math.round((dc.uploaded / dc.required) * 100) : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Status timeline */}
|
{/* Competition timeline or status tracker */}
|
||||||
<AnimatedCard index={2}>
|
<AnimatedCard index={3}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Status Timeline</CardTitle>
|
<CardTitle>
|
||||||
|
{hasPassedIntake ? 'Competition Progress' : 'Status Timeline'}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{hasPassedIntake ? (
|
||||||
|
<CompetitionTimelineSidebar />
|
||||||
|
) : (
|
||||||
<StatusTracker
|
<StatusTracker
|
||||||
timeline={timeline}
|
timeline={timeline}
|
||||||
currentStatus={currentStatus || 'SUBMITTED'}
|
currentStatus={currentStatus || 'SUBMITTED'}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
|
{/* Jury Feedback Card */}
|
||||||
|
{totalEvaluations > 0 && (
|
||||||
|
<AnimatedCard index={4}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Star className="h-5 w-5" />
|
||||||
|
Jury Feedback
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={"/applicant/evaluations" as Route}>
|
||||||
|
View All
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{totalEvaluations} evaluation{totalEvaluations !== 1 ? 's' : ''} available from{' '}
|
||||||
|
{evaluations?.length ?? 0} round{(evaluations?.length ?? 0) !== 1 ? 's' : ''}.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Team overview */}
|
{/* Team overview */}
|
||||||
<AnimatedCard index={3}>
|
<AnimatedCard index={5}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -326,8 +407,32 @@ export default function ApplicantDashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
|
{/* Upcoming Deadlines */}
|
||||||
|
{deadlines && deadlines.length > 0 && (
|
||||||
|
<AnimatedCard index={6}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
Upcoming Deadlines
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{deadlines.map((dl, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium truncate mr-2">{dl.roundName}</span>
|
||||||
|
<span className="text-muted-foreground shrink-0">
|
||||||
|
{new Date(dl.windowCloseAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Key dates */}
|
{/* Key dates */}
|
||||||
<AnimatedCard index={4}>
|
<AnimatedCard index={7}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Key Dates</CardTitle>
|
<CardTitle>Key Dates</CardTitle>
|
||||||
@@ -347,12 +452,6 @@ export default function ApplicantDashboardPage() {
|
|||||||
<span className="text-muted-foreground">Last Updated</span>
|
<span className="text-muted-foreground">Last Updated</span>
|
||||||
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
{openRounds.length > 0 && openRounds[0].windowCloseAt && (
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Deadline</span>
|
|
||||||
<span>{new Date(openRounds[0].windowCloseAt).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|||||||
122
src/app/(applicant)/applicant/resources/[id]/page.tsx
Normal file
122
src/app/(applicant)/applicant/resources/[id]/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const ResourceRenderer = dynamic(
|
||||||
|
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function ApplicantResourceDetailPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const resourceId = params.id as string
|
||||||
|
|
||||||
|
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||||
|
const logAccess = trpc.learningResource.logAccess.useMutation()
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
// Log access on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (resourceId) {
|
||||||
|
logAccess.mutate({ id: resourceId })
|
||||||
|
}
|
||||||
|
}, [resourceId])
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
|
||||||
|
window.open(url, '_blank')
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
<Skeleton className="h-10 w-2/3" />
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
<Skeleton className="h-px w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !resource) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Resource not found</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
This resource may have been removed or you don't have access.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/applicant/resources">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Resources
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="ghost" asChild className="-ml-4">
|
||||||
|
<Link href="/applicant/resources">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Resources
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{resource.externalUrl && (
|
||||||
|
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Open Link
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{resource.objectKey && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ResourceRenderer
|
||||||
|
title={resource.title}
|
||||||
|
description={resource.description}
|
||||||
|
contentJson={resource.contentJson}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
146
src/app/(applicant)/applicant/resources/page.tsx
Normal file
146
src/app/(applicant)/applicant/resources/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
BookOpen,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export default function ApplicantResourcesPage() {
|
||||||
|
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.learningResource.myResources.useQuery({})
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const handleDownload = async (resourceId: string) => {
|
||||||
|
setDownloadingId(resourceId)
|
||||||
|
try {
|
||||||
|
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
|
||||||
|
window.open(url, '_blank')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download failed:', error)
|
||||||
|
} finally {
|
||||||
|
setDownloadingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Resources</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Resources and materials for applicants
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
|
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = data?.resources || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Resources</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Resources and materials for applicants
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{resources.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<BookOpen className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">No resources available</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Check back later for learning materials
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{resources.map((resource) => {
|
||||||
|
const isDownloading = downloadingId === resource.id
|
||||||
|
const hasContent = !!resource.contentJson
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={resource.id}>
|
||||||
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-medium">{resource.title}</h3>
|
||||||
|
{resource.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
{resource.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasContent && (
|
||||||
|
<Link href={`/applicant/resources/${resource.id}` as Route}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<BookOpen className="mr-2 h-4 w-4" />
|
||||||
|
Read
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{resource.externalUrl && (
|
||||||
|
<a
|
||||||
|
href={resource.externalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{resource.objectKey && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDownload(resource.id)}
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
{isDownloading ? 'Loading...' : 'Download'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -280,6 +280,15 @@ export default function ApplicantTeamPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
|
||||||
|
<p className="font-medium mb-1">What invited members can do:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||||
|
<li>Upload documents for submission rounds</li>
|
||||||
|
<li>View project status and competition progress</li>
|
||||||
|
<li>Receive email notifications about round updates</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
|
||||||
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
|||||||
update('advancementConfig', { ...advancementConfig, [key]: value })
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Scoring */}
|
{/* Scoring */}
|
||||||
@@ -202,6 +210,71 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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' 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 */}
|
{/* Advancement */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -4,36 +4,17 @@ import { trpc } from '@/lib/trpc/client'
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { CheckCircle2, Circle, Clock } from 'lucide-react'
|
import { CheckCircle2, Circle, Clock, XCircle, Trophy } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
interface ApplicantCompetitionTimelineProps {
|
const roundStatusDisplay: Record<string, { label: string; variant: 'default' | 'secondary' }> = {
|
||||||
competitionId: string
|
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> = {
|
export function ApplicantCompetitionTimeline() {
|
||||||
completed: CheckCircle2,
|
const { data, isLoading } = trpc.applicant.getMyCompetitionTimeline.useQuery()
|
||||||
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 }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -60,77 +41,117 @@ export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompeti
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-center py-8">
|
<CardContent className="text-center py-8">
|
||||||
<Circle className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rounds = competition.rounds || []
|
|
||||||
const currentRoundIndex = rounds.findIndex(r => r.status === 'ROUND_ACTIVE')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Competition Timeline</CardTitle>
|
<CardTitle>Competition Timeline</CardTitle>
|
||||||
|
{data.competitionName && (
|
||||||
|
<p className="text-sm text-muted-foreground">{data.competitionName}</p>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="relative space-y-6">
|
<div className="relative space-y-6">
|
||||||
{/* Vertical connecting line */}
|
{/* Vertical connecting line */}
|
||||||
<div className="absolute left-5 top-5 bottom-5 w-0.5 bg-border" />
|
<div className="absolute left-5 top-5 bottom-5 w-0.5 bg-border" />
|
||||||
|
|
||||||
{rounds.map((round, index) => {
|
{data.entries.map((entry) => {
|
||||||
const isActive = round.status === 'ROUND_ACTIVE'
|
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
|
||||||
const isCompleted = index < currentRoundIndex || round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED'
|
const isActive = entry.status === 'ROUND_ACTIVE'
|
||||||
const isCurrent = index === currentRoundIndex || isActive
|
const isRejected = entry.projectState === 'REJECTED'
|
||||||
const status = isCompleted ? 'completed' : isCurrent ? 'current' : 'upcoming'
|
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
|
||||||
const Icon = statusIcons[status]
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div key={round.id} className="relative flex items-start gap-4">
|
<div key={entry.id} className="relative flex items-start gap-4">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0 pb-6">
|
<div className="flex-1 min-w-0 pb-6">
|
||||||
<div className="flex items-start justify-between flex-wrap gap-2 mb-2">
|
<div className="flex items-start justify-between flex-wrap gap-2 mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">{round.name}</h3>
|
<h3 className="font-semibold">{entry.label}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{stateLabel && (
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant="outline"
|
||||||
status === 'completed'
|
|
||||||
? 'default'
|
|
||||||
: status === 'current'
|
|
||||||
? 'default'
|
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
className={
|
className={
|
||||||
status === 'completed'
|
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'
|
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||||
: status === 'current'
|
: isActive
|
||||||
? 'bg-brand-blue text-white'
|
? 'bg-brand-blue text-white'
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{status === 'completed' && 'Completed'}
|
{statusInfo.label}
|
||||||
{status === 'current' && 'In Progress'}
|
|
||||||
{status === 'upcoming' && 'Upcoming'}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{round.windowOpenAt && round.windowCloseAt && (
|
{entry.windowOpenAt && entry.windowCloseAt && (
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
<p>
|
<p>Opens: {new Date(entry.windowOpenAt).toLocaleDateString()}</p>
|
||||||
Opens: {new Date(round.windowOpenAt).toLocaleDateString()}
|
<p>Closes: {new Date(entry.windowCloseAt).toLocaleDateString()}</p>
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Closes: {new Date(round.windowCloseAt).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -142,3 +163,76 @@ export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompeti
|
|||||||
</Card>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'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'
|
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||||
|
|
||||||
interface ApplicantNavProps {
|
interface ApplicantNavProps {
|
||||||
@@ -8,32 +9,22 @@ interface ApplicantNavProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ApplicantNav({ user }: ApplicantNavProps) {
|
export function ApplicantNav({ user }: ApplicantNavProps) {
|
||||||
|
const { data: flags } = trpc.applicant.getNavFlags.useQuery(undefined, {
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
const navigation: NavItem[] = [
|
const navigation: NavItem[] = [
|
||||||
{
|
{ name: 'Dashboard', href: '/applicant', icon: Home },
|
||||||
name: 'Dashboard',
|
{ name: 'Team', href: '/applicant/team', icon: Users },
|
||||||
href: '/applicant',
|
{ name: 'Competition', href: '/applicant/competition', icon: Trophy },
|
||||||
icon: Home,
|
{ name: 'Documents', href: '/applicant/documents', icon: FileText },
|
||||||
},
|
...(flags?.hasEvaluationRounds
|
||||||
{
|
? [{ name: 'Evaluations', href: '/applicant/evaluations', icon: Star }]
|
||||||
name: 'Team',
|
: []),
|
||||||
href: '/applicant/team',
|
...(flags?.hasMentor
|
||||||
icon: Users,
|
? [{ name: 'Mentoring', href: '/applicant/mentor', icon: MessageSquare }]
|
||||||
},
|
: []),
|
||||||
{
|
{ name: 'Resources', href: '/applicant/resources', icon: BookOpen },
|
||||||
name: 'Competitions',
|
|
||||||
href: '/applicant/competitions',
|
|
||||||
icon: Layers,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Documents',
|
|
||||||
href: '/applicant/documents',
|
|
||||||
icon: FileText,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mentoring',
|
|
||||||
href: '/applicant/mentor',
|
|
||||||
icon: MessageSquare,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/em
|
|||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
import { createNotification } from '../services/in-app-notification'
|
import { createNotification } from '../services/in-app-notification'
|
||||||
import { checkRequirementsAndTransition } from '../services/round-engine'
|
import { checkRequirementsAndTransition } from '../services/round-engine'
|
||||||
|
import { EvaluationConfigSchema } from '@/types/competition-configs'
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
// Bucket for applicant submissions
|
// Bucket for applicant submissions
|
||||||
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
||||||
@@ -1278,6 +1280,12 @@ export const applicantRouter = router({
|
|||||||
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
|
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
|
||||||
const isTeamLead = project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD'
|
const isTeamLead = project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD'
|
||||||
|
|
||||||
|
// Check if project has passed intake
|
||||||
|
const passedIntake = await ctx.prisma.projectRoundState.findFirst({
|
||||||
|
where: { projectId: project.id, state: 'PASSED', round: { roundType: 'INTAKE' } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
project: {
|
project: {
|
||||||
...project,
|
...project,
|
||||||
@@ -1287,6 +1295,461 @@ export const applicantRouter = router({
|
|||||||
openRounds,
|
openRounds,
|
||||||
timeline,
|
timeline,
|
||||||
currentStatus,
|
currentStatus,
|
||||||
|
hasPassedIntake: !!passedIntake,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight flags for conditional nav rendering.
|
||||||
|
*/
|
||||||
|
getNavFlags: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
if (ctx.user.role !== 'APPLICANT') {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await ctx.prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ submittedByUserId: ctx.user.id },
|
||||||
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
programId: true,
|
||||||
|
mentorAssignment: { select: { id: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return { hasMentor: false, hasEvaluationRounds: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if mentor is assigned
|
||||||
|
const hasMentor = !!project.mentorAssignment
|
||||||
|
|
||||||
|
// Check if there are EVALUATION rounds (CLOSED/ARCHIVED) with applicantVisibility.enabled
|
||||||
|
let hasEvaluationRounds = false
|
||||||
|
if (project.programId) {
|
||||||
|
const closedEvalRounds = await ctx.prisma.round.findMany({
|
||||||
|
where: {
|
||||||
|
competition: { programId: project.programId },
|
||||||
|
roundType: 'EVALUATION',
|
||||||
|
status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
|
||||||
|
},
|
||||||
|
select: { configJson: true },
|
||||||
|
})
|
||||||
|
hasEvaluationRounds = closedEvalRounds.some((r) => {
|
||||||
|
const parsed = EvaluationConfigSchema.safeParse(r.configJson)
|
||||||
|
return parsed.success && parsed.data.applicantVisibility.enabled
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hasMentor, hasEvaluationRounds }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtered competition timeline showing only EVALUATION + Grand Finale.
|
||||||
|
* Hides FILTERING/INTAKE/SUBMISSION/MENTORING from applicants.
|
||||||
|
*/
|
||||||
|
getMyCompetitionTimeline: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
if (ctx.user.role !== 'APPLICANT') {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await ctx.prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ submittedByUserId: ctx.user.id },
|
||||||
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true, programId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!project?.programId) {
|
||||||
|
return { competitionName: null, entries: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find competition via programId (fixes the programId/competitionId bug)
|
||||||
|
const competition = await ctx.prisma.competition.findFirst({
|
||||||
|
where: { programId: project.programId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!competition) {
|
||||||
|
return { competitionName: null, entries: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all rounds ordered by sortOrder
|
||||||
|
const rounds = await ctx.prisma.round.findMany({
|
||||||
|
where: { competitionId: competition.id },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
roundType: true,
|
||||||
|
status: true,
|
||||||
|
windowOpenAt: true,
|
||||||
|
windowCloseAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get all ProjectRoundState for this project
|
||||||
|
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: { projectId: project.id },
|
||||||
|
select: { roundId: true, state: true },
|
||||||
|
})
|
||||||
|
const stateMap = new Map(projectStates.map((ps) => [ps.roundId, ps.state]))
|
||||||
|
|
||||||
|
type TimelineEntry = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
roundType: 'EVALUATION' | 'GRAND_FINALE'
|
||||||
|
status: string
|
||||||
|
windowOpenAt: Date | null
|
||||||
|
windowCloseAt: Date | null
|
||||||
|
projectState: string | null
|
||||||
|
isSynthesizedRejection: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: TimelineEntry[] = []
|
||||||
|
|
||||||
|
// Build lookup for filtering rounds and their next evaluation round
|
||||||
|
const filteringRounds = rounds.filter((r) => r.roundType === 'FILTERING')
|
||||||
|
const evalRounds = rounds.filter((r) => r.roundType === 'EVALUATION')
|
||||||
|
const liveFinalRounds = rounds.filter((r) => r.roundType === 'LIVE_FINAL')
|
||||||
|
const deliberationRounds = rounds.filter((r) => r.roundType === 'DELIBERATION')
|
||||||
|
|
||||||
|
// Process EVALUATION rounds
|
||||||
|
for (const evalRound of evalRounds) {
|
||||||
|
const actualState = stateMap.get(evalRound.id) ?? null
|
||||||
|
|
||||||
|
// Check if a FILTERING round before this eval round rejected the project
|
||||||
|
let projectState = actualState
|
||||||
|
let isSynthesizedRejection = false
|
||||||
|
|
||||||
|
// Find FILTERING rounds that come before this eval round in sortOrder
|
||||||
|
const evalSortOrder = rounds.findIndex((r) => r.id === evalRound.id)
|
||||||
|
const precedingFilterRounds = filteringRounds.filter((fr) => {
|
||||||
|
const frIdx = rounds.findIndex((r) => r.id === fr.id)
|
||||||
|
return frIdx < evalSortOrder
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const fr of precedingFilterRounds) {
|
||||||
|
const filterState = stateMap.get(fr.id)
|
||||||
|
if (filterState === 'REJECTED') {
|
||||||
|
projectState = 'REJECTED'
|
||||||
|
isSynthesizedRejection = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if ((filterState === 'IN_PROGRESS' || filterState === 'PENDING') && !actualState) {
|
||||||
|
projectState = 'IN_PROGRESS'
|
||||||
|
isSynthesizedRejection = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
id: evalRound.id,
|
||||||
|
label: evalRound.name,
|
||||||
|
roundType: 'EVALUATION',
|
||||||
|
status: evalRound.status,
|
||||||
|
windowOpenAt: evalRound.windowOpenAt,
|
||||||
|
windowCloseAt: evalRound.windowCloseAt,
|
||||||
|
projectState,
|
||||||
|
isSynthesizedRejection,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grand Finale: combine LIVE_FINAL + DELIBERATION
|
||||||
|
if (liveFinalRounds.length > 0 || deliberationRounds.length > 0) {
|
||||||
|
const grandFinaleRounds = [...liveFinalRounds, ...deliberationRounds]
|
||||||
|
|
||||||
|
// Project state: prefer LIVE_FINAL state, then DELIBERATION
|
||||||
|
let gfState: string | null = null
|
||||||
|
for (const lfr of liveFinalRounds) {
|
||||||
|
const s = stateMap.get(lfr.id)
|
||||||
|
if (s) { gfState = s; break }
|
||||||
|
}
|
||||||
|
if (!gfState) {
|
||||||
|
for (const dr of deliberationRounds) {
|
||||||
|
const s = stateMap.get(dr.id)
|
||||||
|
if (s) { gfState = s; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status: most advanced status among grouped rounds
|
||||||
|
const statusPriority: Record<string, number> = {
|
||||||
|
ROUND_ARCHIVED: 3,
|
||||||
|
ROUND_CLOSED: 2,
|
||||||
|
ROUND_ACTIVE: 1,
|
||||||
|
ROUND_DRAFT: 0,
|
||||||
|
}
|
||||||
|
let gfStatus = 'ROUND_DRAFT'
|
||||||
|
for (const r of grandFinaleRounds) {
|
||||||
|
if ((statusPriority[r.status] ?? 0) > (statusPriority[gfStatus] ?? 0)) {
|
||||||
|
gfStatus = r.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use earliest window open and latest window close
|
||||||
|
const openDates = grandFinaleRounds.map((r) => r.windowOpenAt).filter(Boolean) as Date[]
|
||||||
|
const closeDates = grandFinaleRounds.map((r) => r.windowCloseAt).filter(Boolean) as Date[]
|
||||||
|
|
||||||
|
// Check if a prior filtering rejection should propagate
|
||||||
|
let isSynthesizedRejection = false
|
||||||
|
const gfSortOrder = Math.min(
|
||||||
|
...grandFinaleRounds.map((r) => rounds.findIndex((rr) => rr.id === r.id))
|
||||||
|
)
|
||||||
|
for (const fr of filteringRounds) {
|
||||||
|
const frIdx = rounds.findIndex((r) => r.id === fr.id)
|
||||||
|
if (frIdx < gfSortOrder && stateMap.get(fr.id) === 'REJECTED') {
|
||||||
|
gfState = 'REJECTED'
|
||||||
|
isSynthesizedRejection = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
id: 'grand-finale',
|
||||||
|
label: 'Grand Finale',
|
||||||
|
roundType: 'GRAND_FINALE',
|
||||||
|
status: gfStatus,
|
||||||
|
windowOpenAt: openDates.length > 0 ? new Date(Math.min(...openDates.map((d) => d.getTime()))) : null,
|
||||||
|
windowCloseAt: closeDates.length > 0 ? new Date(Math.max(...closeDates.map((d) => d.getTime()))) : null,
|
||||||
|
projectState: gfState,
|
||||||
|
isSynthesizedRejection,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle projects manually created at a non-intake round:
|
||||||
|
// If a project has state in a later round but not earlier, mark prior rounds as PASSED.
|
||||||
|
// Find the earliest visible entry (EVALUATION or GRAND_FINALE) that has a real state.
|
||||||
|
const firstEntryWithState = entries.findIndex(
|
||||||
|
(e) => e.projectState !== null && !e.isSynthesizedRejection
|
||||||
|
)
|
||||||
|
if (firstEntryWithState > 0) {
|
||||||
|
// All entries before the first real state should show as PASSED (if the round is closed/archived)
|
||||||
|
for (let i = 0; i < firstEntryWithState; i++) {
|
||||||
|
const entry = entries[i]
|
||||||
|
if (!entry.projectState) {
|
||||||
|
const roundClosed = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
|
||||||
|
if (roundClosed) {
|
||||||
|
entry.projectState = 'PASSED'
|
||||||
|
entry.isSynthesizedRejection = false // not a rejection, it's a synthesized pass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the project was rejected in filtering and there are entries after,
|
||||||
|
// null-out states for entries after the rejection point
|
||||||
|
let foundRejection = false
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (foundRejection) {
|
||||||
|
entry.projectState = null
|
||||||
|
}
|
||||||
|
if (entry.projectState === 'REJECTED' && entry.isSynthesizedRejection) {
|
||||||
|
foundRejection = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { competitionName: competition.name, entries }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get anonymous jury evaluations visible to the applicant.
|
||||||
|
* Respects per-round applicantVisibility config. NEVER leaks juror identity.
|
||||||
|
*/
|
||||||
|
getMyEvaluations: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
if (ctx.user.role !== 'APPLICANT') {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await ctx.prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ submittedByUserId: ctx.user.id },
|
||||||
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true, programId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!project?.programId) return []
|
||||||
|
|
||||||
|
// Get closed/archived EVALUATION rounds for this competition
|
||||||
|
const evalRounds = await ctx.prisma.round.findMany({
|
||||||
|
where: {
|
||||||
|
competition: { programId: project.programId },
|
||||||
|
roundType: 'EVALUATION',
|
||||||
|
status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
configJson: true,
|
||||||
|
},
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const results: Array<{
|
||||||
|
roundId: string
|
||||||
|
roundName: string
|
||||||
|
evaluationCount: number
|
||||||
|
evaluations: Array<{
|
||||||
|
id: string
|
||||||
|
submittedAt: Date | null
|
||||||
|
globalScore: number | null
|
||||||
|
criterionScores: Prisma.JsonValue | null
|
||||||
|
feedbackText: string | null
|
||||||
|
criteria: Prisma.JsonValue | null
|
||||||
|
}>
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
for (const round of evalRounds) {
|
||||||
|
const parsed = EvaluationConfigSchema.safeParse(round.configJson)
|
||||||
|
if (!parsed.success || !parsed.data.applicantVisibility.enabled) continue
|
||||||
|
|
||||||
|
const vis = parsed.data.applicantVisibility
|
||||||
|
|
||||||
|
// Get evaluations via assignments — NEVER select userId or user relation
|
||||||
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||||
|
where: {
|
||||||
|
assignment: {
|
||||||
|
projectId: project.id,
|
||||||
|
roundId: round.id,
|
||||||
|
},
|
||||||
|
status: { in: ['SUBMITTED', 'LOCKED'] },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
submittedAt: true,
|
||||||
|
globalScore: vis.showGlobalScore,
|
||||||
|
criterionScoresJson: vis.showCriterionScores,
|
||||||
|
feedbackText: vis.showFeedbackText,
|
||||||
|
form: vis.showCriterionScores ? { select: { criteriaJson: true } } : false,
|
||||||
|
},
|
||||||
|
orderBy: { submittedAt: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
roundId: round.id,
|
||||||
|
roundName: round.name,
|
||||||
|
evaluationCount: evaluations.length,
|
||||||
|
evaluations: evaluations.map((ev) => ({
|
||||||
|
id: ev.id,
|
||||||
|
submittedAt: ev.submittedAt,
|
||||||
|
globalScore: vis.showGlobalScore ? (ev as { globalScore?: number | null }).globalScore ?? null : null,
|
||||||
|
criterionScores: vis.showCriterionScores ? (ev as { criterionScoresJson?: Prisma.JsonValue }).criterionScoresJson ?? null : null,
|
||||||
|
feedbackText: vis.showFeedbackText ? (ev as { feedbackText?: string | null }).feedbackText ?? null : null,
|
||||||
|
criteria: vis.showCriterionScores ? ((ev as { form?: { criteriaJson: Prisma.JsonValue } | null }).form?.criteriaJson ?? null) : null,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upcoming deadlines for dashboard card.
|
||||||
|
*/
|
||||||
|
getUpcomingDeadlines: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
if (ctx.user.role !== 'APPLICANT') {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await ctx.prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ submittedByUserId: ctx.user.id },
|
||||||
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { programId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!project?.programId) return []
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const rounds = await ctx.prisma.round.findMany({
|
||||||
|
where: {
|
||||||
|
competition: { programId: project.programId },
|
||||||
|
status: 'ROUND_ACTIVE',
|
||||||
|
windowCloseAt: { gt: now },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
windowCloseAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { windowCloseAt: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
return rounds.map((r) => ({
|
||||||
|
roundName: r.name,
|
||||||
|
windowCloseAt: r.windowCloseAt!,
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document completeness progress for dashboard card.
|
||||||
|
*/
|
||||||
|
getDocumentCompleteness: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
if (ctx.user.role !== 'APPLICANT') {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await ctx.prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ submittedByUserId: ctx.user.id },
|
||||||
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true, programId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!project?.programId) return []
|
||||||
|
|
||||||
|
// Find active rounds with file requirements
|
||||||
|
const rounds = await ctx.prisma.round.findMany({
|
||||||
|
where: {
|
||||||
|
competition: { programId: project.programId },
|
||||||
|
status: 'ROUND_ACTIVE',
|
||||||
|
fileRequirements: { some: {} },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
fileRequirements: {
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const results: Array<{ roundId: string; roundName: string; required: number; uploaded: number }> = []
|
||||||
|
|
||||||
|
for (const round of rounds) {
|
||||||
|
const requirementIds = round.fileRequirements.map((fr) => fr.id)
|
||||||
|
if (requirementIds.length === 0) continue
|
||||||
|
|
||||||
|
const uploaded = await ctx.prisma.projectFile.count({
|
||||||
|
where: {
|
||||||
|
projectId: project.id,
|
||||||
|
requirementId: { in: requirementIds },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
roundId: round.id,
|
||||||
|
roundName: round.name,
|
||||||
|
required: requirementIds.length,
|
||||||
|
uploaded,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -109,6 +109,20 @@ export const EvaluationConfigSchema = z.object({
|
|||||||
generateAiShortlist: z.boolean().default(false),
|
generateAiShortlist: z.boolean().default(false),
|
||||||
aiParseFiles: z.boolean().default(false),
|
aiParseFiles: z.boolean().default(false),
|
||||||
|
|
||||||
|
applicantVisibility: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
showGlobalScore: z.boolean().default(false),
|
||||||
|
showCriterionScores: z.boolean().default(false),
|
||||||
|
showFeedbackText: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.default({
|
||||||
|
enabled: false,
|
||||||
|
showGlobalScore: false,
|
||||||
|
showCriterionScores: false,
|
||||||
|
showFeedbackText: false,
|
||||||
|
}),
|
||||||
|
|
||||||
advancementMode: z
|
advancementMode: z
|
||||||
.enum(['auto_top_n', 'admin_selection', 'ai_recommended'])
|
.enum(['auto_top_n', 'admin_selection', 'ai_recommended'])
|
||||||
.default('admin_selection'),
|
.default('admin_selection'),
|
||||||
|
|||||||
Reference in New Issue
Block a user