diff --git a/next.config.ts b/next.config.ts index 8f33ac4..7356c00 100644 --- a/next.config.ts +++ b/next.config.ts @@ -40,12 +40,12 @@ const nextConfig: NextConfig = { }, { source: '/applicant/pipeline', - destination: '/applicant/competitions', + destination: '/applicant/competition', permanent: true, }, { source: '/applicant/pipeline/:path*', - destination: '/applicant/competitions', + destination: '/applicant/competition', permanent: true, }, ] diff --git a/src/app/(applicant)/applicant/competitions/page.tsx b/src/app/(applicant)/applicant/competition/page.tsx similarity index 93% rename from src/app/(applicant)/applicant/competitions/page.tsx rename to src/app/(applicant)/applicant/competition/page.tsx index 4cbe83e..eb68aba 100644 --- a/src/app/(applicant)/applicant/competitions/page.tsx +++ b/src/app/(applicant)/applicant/competition/page.tsx @@ -4,14 +4,13 @@ import { useSession } from 'next-auth/react' 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline' 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: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, { enabled: !!session, @@ -26,7 +25,7 @@ export default function ApplicantCompetitionsPage() { ) } - const competitionId = myProject?.project?.programId + const hasProject = !!myProject?.project return (
@@ -45,7 +44,7 @@ export default function ApplicantCompetitionsPage() {
- {!competitionId ? ( + {!hasProject ? ( @@ -59,7 +58,7 @@ export default function ApplicantCompetitionsPage() { ) : (
- +
diff --git a/src/app/(applicant)/applicant/competitions/[windowId]/page.tsx b/src/app/(applicant)/applicant/competitions/[windowId]/page.tsx deleted file mode 100644 index a68ef48..0000000 --- a/src/app/(applicant)/applicant/competitions/[windowId]/page.tsx +++ /dev/null @@ -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>({}) - - 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 ( -
- - -
- ) - } - - if (!window) { - return ( -
- - - - -

Submission window not found

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

{window.name}

-

- Upload required documents for this submission window -

-
-
- - {/* Deadline card */} - {deadline && ( - - - {isLocked ? ( - <> - -
-

Submission Window Closed

-

- This submission window closed on {deadline.toLocaleDateString()}. No further - uploads are allowed. -

-
- - ) : ( - <> - -
-

Deadline Countdown

-
- - {daysRemaining !== null ? daysRemaining : '—'} - - - day{daysRemaining !== 1 ? 's' : ''} remaining - -
-

- Due: {deadline.toLocaleString()} -

-
- - )} -
-
- )} - - {/* File requirements */} - - - File Requirements - - Upload the required files below. {isLocked && 'Viewing only - window is closed.'} - - - - {/* File requirements would be fetched separately in a real implementation */} - {false ? ( - [].map((req: any) => ( - handleUpload(req.id, file)} - /> - )) - ) : ( -
- -

- No file requirements defined for this window -

-
- )} -
-
- - {!isLocked && ( -
- - -
- )} -
- ) -} diff --git a/src/app/(applicant)/applicant/evaluations/page.tsx b/src/app/(applicant)/applicant/evaluations/page.tsx new file mode 100644 index 0000000..f4f8acb --- /dev/null +++ b/src/app/(applicant)/applicant/evaluations/page.tsx @@ -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 ( +
+
+

Jury Feedback

+

Anonymous evaluations from jury members

+
+
+ {[1, 2].map((i) => ( + + + + + + + ))} +
+
+ ) + } + + const hasEvaluations = rounds && rounds.length > 0 + + return ( +
+
+

Jury Feedback

+

+ Anonymous evaluations from jury members +

+
+ + {!hasEvaluations ? ( + + + +

No Evaluations Available

+

+ Evaluations will appear here once jury review is complete and results are published. +

+
+
+ ) : ( +
+ {rounds.map((round) => ( + + +
+ {round.roundName} + + {round.evaluationCount} evaluation{round.evaluationCount !== 1 ? 's' : ''} + +
+
+ + {round.evaluations.map((ev, idx) => ( +
+
+ + Evaluator #{idx + 1} + + {ev.submittedAt && ( + + {new Date(ev.submittedAt).toLocaleDateString()} + + )} +
+ + {ev.globalScore !== null && ( +
+ + {ev.globalScore} + / 100 +
+ )} + + {ev.criterionScores && ev.criteria && ( +
+

Criterion Scores

+
+ {(() => { + const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }> + const scores = ev.criterionScores as Record + return criteria + .filter((c) => c.id || c.label || c.name) + .map((c, ci) => { + const key = c.id || String(ci) + const score = scores[key] + return ( +
+ {c.label || c.name || `Criterion ${ci + 1}`} + + {score !== undefined ? score : '—'} + {c.maxScore ? ` / ${c.maxScore}` : ''} + +
+ ) + }) + })()} +
+
+ )} + + {ev.feedbackText && ( +
+
+ + Written Feedback +
+
+ {ev.feedbackText} +
+
+ )} +
+ ))} +
+
+ ))} + +

+ Evaluator identities are kept confidential. +

+
+ )} +
+ ) +} diff --git a/src/app/(applicant)/applicant/page.tsx b/src/app/(applicant)/applicant/page.tsx index 9600062..0da9ea6 100644 --- a/src/app/(applicant)/applicant/page.tsx +++ b/src/app/(applicant)/applicant/page.tsx @@ -9,25 +9,26 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, - CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { StatusTracker } from '@/components/shared/status-tracker' +import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline' import { AnimatedCard } from '@/components/shared/animated-container' import { FileText, Calendar, Clock, - AlertCircle, CheckCircle, Users, Crown, MessageSquare, Upload, ArrowRight, + Star, + AlertCircle, } from 'lucide-react' const statusColors: Record = { @@ -49,6 +50,18 @@ export default function ApplicantDashboardPage() { 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) { return (
@@ -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 programYear = project.program?.year const programName = project.program?.name + const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0 return (
@@ -213,7 +227,7 @@ export default function ApplicantDashboardPage() { {/* Quick actions */} -
+
@@ -240,41 +254,108 @@ export default function ApplicantDashboardPage() { - -
- -
-
-

Mentor

-

- {project.mentorAssignment?.mentor?.name || 'Not assigned'} -

-
- - + {project.mentorAssignment && ( + +
+ +
+
+

Mentor

+

+ {project.mentorAssignment.mentor?.name || 'Assigned'} +

+
+ + + )}
+ + {/* Document Completeness */} + {docCompleteness && docCompleteness.length > 0 && ( + + + + + + Document Progress + + + + {docCompleteness.map((dc) => ( +
+
+ {dc.roundName} + + {dc.uploaded}/{dc.required} files + +
+
+
0 ? Math.round((dc.uploaded / dc.required) * 100) : 0}%` }} + /> +
+
+ ))} + + + + )}
{/* Sidebar */}
- {/* Status timeline */} - + {/* Competition timeline or status tracker */} + - Status Timeline + + {hasPassedIntake ? 'Competition Progress' : 'Status Timeline'} + - + {hasPassedIntake ? ( + + ) : ( + + )} + {/* Jury Feedback Card */} + {totalEvaluations > 0 && ( + + + +
+ + + Jury Feedback + + +
+
+ +

+ {totalEvaluations} evaluation{totalEvaluations !== 1 ? 's' : ''} available from{' '} + {evaluations?.length ?? 0} round{(evaluations?.length ?? 0) !== 1 ? 's' : ''}. +

+
+
+
+ )} + {/* Team overview */} - +
@@ -326,8 +407,32 @@ export default function ApplicantDashboardPage() { + {/* Upcoming Deadlines */} + {deadlines && deadlines.length > 0 && ( + + + + + + Upcoming Deadlines + + + + {deadlines.map((dl, i) => ( +
+ {dl.roundName} + + {new Date(dl.windowCloseAt).toLocaleDateString()} + +
+ ))} +
+
+
+ )} + {/* Key dates */} - + Key Dates @@ -347,12 +452,6 @@ export default function ApplicantDashboardPage() { Last Updated {new Date(project.updatedAt).toLocaleDateString()}
- {openRounds.length > 0 && openRounds[0].windowCloseAt && ( -
- Deadline - {new Date(openRounds[0].windowCloseAt).toLocaleDateString()} -
- )}
diff --git a/src/app/(applicant)/applicant/resources/[id]/page.tsx b/src/app/(applicant)/applicant/resources/[id]/page.tsx new file mode 100644 index 0000000..3e93d10 --- /dev/null +++ b/src/app/(applicant)/applicant/resources/[id]/page.tsx @@ -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: () => ( +
+ ), + } +) + +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 ( +
+ +
+ + + + +
+
+ ) + } + + if (error || !resource) { + return ( +
+ + + Resource not found + + This resource may have been removed or you don't have access. + + + +
+ ) + } + + return ( +
+ {/* Header */} +
+ +
+ {resource.externalUrl && ( + + + + )} + {resource.objectKey && ( + + )} +
+
+ + {/* Content */} + +
+ ) +} diff --git a/src/app/(applicant)/applicant/resources/page.tsx b/src/app/(applicant)/applicant/resources/page.tsx new file mode 100644 index 0000000..cb0c269 --- /dev/null +++ b/src/app/(applicant)/applicant/resources/page.tsx @@ -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(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 ( +
+
+

Resources

+

+ Resources and materials for applicants +

+
+
+ {[1, 2, 3].map((i) => ( + + + +
+ + +
+
+
+ ))} +
+
+ ) + } + + const resources = data?.resources || [] + + return ( +
+
+

Resources

+

+ Resources and materials for applicants +

+
+ + {resources.length === 0 ? ( + + + +

No resources available

+

+ Check back later for learning materials +

+
+
+ ) : ( +
+ {resources.map((resource) => { + const isDownloading = downloadingId === resource.id + const hasContent = !!resource.contentJson + + return ( + + +
+ +
+
+

{resource.title}

+ {resource.description && ( +

+ {resource.description} +

+ )} +
+
+ {hasContent && ( + + + + )} + {resource.externalUrl && ( + + + + )} + {resource.objectKey && ( + + )} +
+
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/src/app/(applicant)/applicant/team/page.tsx b/src/app/(applicant)/applicant/team/page.tsx index de2c1f9..a44ca7e 100644 --- a/src/app/(applicant)/applicant/team/page.tsx +++ b/src/app/(applicant)/applicant/team/page.tsx @@ -280,6 +280,15 @@ export default function ApplicantTeamPage() { />
+
+

What invited members can do:

+
    +
  • Upload documents for submission rounds
  • +
  • View project status and competition progress
  • +
  • Receive email notifications about round updates
  • +
+

Only the Team Lead can invite or remove members.

+