Performance optimization, applicant portal, and missing DB migration
Build and Push Docker Image / build (push) Successful in 14m6s Details

Performance:
- Convert admin dashboard from SSR to client-side tRPC (fixes 503/ChunkLoadError)
- New dashboard.getStats tRPC endpoint batches 16 queries into single response
- Parallelize jury dashboard queries (assignments + gracePeriods via Promise.all)
- Add project.getFullDetail combined endpoint (project + assignments + stats)
- Configure Prisma connection pool (connection_limit=20, pool_timeout=10)
- Add optimizePackageImports for lucide-react tree-shaking
- Increase React Query staleTime from 1min to 5min

Applicant portal:
- Add applicant layout, nav, dashboard, documents, team, and mentor pages
- Add applicant router with document and team management endpoints
- Add chunk error recovery utility
- Update role nav and auth redirect for applicant role

Database:
- Add migration for missing schema elements (SpecialAward job tracking
  columns, WizardTemplate table, missing indexes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-11 11:04:26 +01:00
parent 09091d7c08
commit 98f4a957cc
32 changed files with 3002 additions and 1121 deletions

View File

@ -5,6 +5,9 @@ const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
typedRoutes: true, typedRoutes: true,
serverExternalPackages: ['@prisma/client', 'minio'], serverExternalPackages: ['@prisma/client', 'minio'],
experimental: {
optimizePackageImports: ['lucide-react'],
},
images: { images: {
remotePatterns: [ remotePatterns: [
{ {

View File

@ -0,0 +1,129 @@
-- Migration: Add all missing schema elements not covered by previous migrations
-- This brings the database fully in line with prisma/schema.prisma
-- Uses IF NOT EXISTS / DO $$ guards for idempotent execution
-- =============================================================================
-- 1. MISSING TABLE: WizardTemplate
-- =============================================================================
CREATE TABLE IF NOT EXISTS "WizardTemplate" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"config" JSONB NOT NULL,
"isGlobal" BOOLEAN NOT NULL DEFAULT false,
"programId" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WizardTemplate_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "WizardTemplate_programId_idx" ON "WizardTemplate"("programId");
CREATE INDEX IF NOT EXISTS "WizardTemplate_isGlobal_idx" ON "WizardTemplate"("isGlobal");
DO $$ BEGIN
ALTER TABLE "WizardTemplate" ADD CONSTRAINT "WizardTemplate_programId_fkey"
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "WizardTemplate" ADD CONSTRAINT "WizardTemplate_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- =============================================================================
-- 2. MISSING COLUMNS ON SpecialAward: eligibility job tracking fields
-- =============================================================================
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobStatus" TEXT;
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobTotal" INTEGER;
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobDone" INTEGER;
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobError" TEXT;
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobStarted" TIMESTAMP(3);
-- =============================================================================
-- 3. Project.referralSource: Already in init migration. No action needed.
-- Round.slug: Already in init migration. No action needed.
-- =============================================================================
-- =============================================================================
-- 5. MISSING INDEXES
-- =============================================================================
-- 5a. Assignment: @@index([projectId, userId])
CREATE INDEX IF NOT EXISTS "Assignment_projectId_userId_idx" ON "Assignment"("projectId", "userId");
-- 5b. AuditLog: @@index([sessionId])
CREATE INDEX IF NOT EXISTS "AuditLog_sessionId_idx" ON "AuditLog"("sessionId");
-- 5c. ProjectFile: @@index([projectId, roundId])
CREATE INDEX IF NOT EXISTS "ProjectFile_projectId_roundId_idx" ON "ProjectFile"("projectId", "roundId");
-- 5d. MessageRecipient: @@index([userId])
CREATE INDEX IF NOT EXISTS "MessageRecipient_userId_idx" ON "MessageRecipient"("userId");
-- 5e. MessageRecipient: @@unique([messageId, userId, channel])
CREATE UNIQUE INDEX IF NOT EXISTS "MessageRecipient_messageId_userId_channel_key" ON "MessageRecipient"("messageId", "userId", "channel");
-- 5f. AwardEligibility: @@index([awardId, eligible]) - composite index
CREATE INDEX IF NOT EXISTS "AwardEligibility_awardId_eligible_idx" ON "AwardEligibility"("awardId", "eligible");
-- =============================================================================
-- 6. REMOVE STALE INDEX: Message_scheduledAt_idx
-- The schema does NOT have @@index([scheduledAt]) on Message.
-- The add_15_features migration created it, but the schema doesn't list it.
-- Leaving it as-is since it's harmless and could be useful.
-- =============================================================================
-- =============================================================================
-- 7. VERIFY: All models from add_15_features are present
-- DigestLog, RoundTemplate, MentorNote, MentorMilestone,
-- MentorMilestoneCompletion, Message, MessageTemplate, MessageRecipient,
-- Webhook, WebhookDelivery, EvaluationDiscussion, DiscussionComment
-- -> All confirmed created in 20260205223133_add_15_features migration.
-- -> All FKs confirmed in add_15_features + 20260208000000_add_missing_fks_indexes.
-- =============================================================================
-- =============================================================================
-- 8. VERIFY: Existing tables from init and subsequent migrations
-- All core tables (User, Account, Session, VerificationToken, Program, Round,
-- EvaluationForm, Project, ProjectFile, Assignment, Evaluation, GracePeriod,
-- SystemSettings, AuditLog, AIUsageLog, NotificationLog, InAppNotification,
-- NotificationEmailSetting, LearningResource, ResourceAccess, Partner,
-- ExpertiseTag, ProjectTag, LiveVotingSession, LiveVote, TeamMember,
-- MentorAssignment, FilteringRule, FilteringResult, FilteringJob,
-- AssignmentJob, TaggingJob, SpecialAward, AwardEligibility, AwardJuror,
-- AwardVote, ReminderLog, ConflictOfInterest, EvaluationSummary,
-- ProjectStatusHistory, MentorMessage, FileRequirement)
-- -> All confirmed present in migrations.
-- =============================================================================
-- =============================================================================
-- SUMMARY OF CHANGES IN THIS MIGRATION:
--
-- NEW TABLE:
-- - WizardTemplate (with programId FK, createdBy FK, indexes)
--
-- NEW COLUMNS:
-- - SpecialAward.eligibilityJobStatus (TEXT, nullable)
-- - SpecialAward.eligibilityJobTotal (INTEGER, nullable)
-- - SpecialAward.eligibilityJobDone (INTEGER, nullable)
-- - SpecialAward.eligibilityJobError (TEXT, nullable)
-- - SpecialAward.eligibilityJobStarted (TIMESTAMP, nullable)
--
-- NEW INDEXES:
-- - Assignment_projectId_userId_idx
-- - AuditLog_sessionId_idx
-- - ProjectFile_projectId_roundId_idx
-- - MessageRecipient_userId_idx
-- - MessageRecipient_messageId_userId_channel_key (UNIQUE)
-- - AwardEligibility_awardId_eligible_idx
-- - WizardTemplate_programId_idx
-- - WizardTemplate_isGlobal_idx
--
-- NEW FOREIGN KEYS:
-- - WizardTemplate_programId_fkey -> Program(id) ON DELETE CASCADE
-- - WizardTemplate_createdBy_fkey -> User(id) ON DELETE RESTRICT
-- =============================================================================

View File

@ -0,0 +1,792 @@
'use client'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import {
CircleDot,
ClipboardList,
Users,
CheckCircle2,
Calendar,
TrendingUp,
ArrowRight,
Layers,
Activity,
AlertTriangle,
ShieldAlert,
Plus,
Upload,
UserPlus,
FileEdit,
LogIn,
Send,
Eye,
Trash2,
} from 'lucide-react'
import { GeographicSummaryCard } from '@/components/charts'
import { AnimatedCard } from '@/components/shared/animated-container'
import { StatusBadge } from '@/components/shared/status-badge'
import { ProjectLogo } from '@/components/shared/project-logo'
import { getCountryName } from '@/lib/countries'
import {
formatDateOnly,
formatEnumLabel,
formatRelativeTime,
truncate,
daysUntil,
} from '@/lib/utils'
type DashboardContentProps = {
editionId: string
sessionName: string
}
function formatAction(action: string, entityType: string | null): string {
const entity = entityType?.toLowerCase() || 'record'
const actionMap: Record<string, string> = {
CREATE: `created a ${entity}`,
UPDATE: `updated a ${entity}`,
DELETE: `deleted a ${entity}`,
LOGIN: 'logged in',
EXPORT: `exported ${entity} data`,
SUBMIT: `submitted an ${entity}`,
ASSIGN: `assigned a ${entity}`,
INVITE: `invited a user`,
STATUS_CHANGE: `changed ${entity} status`,
BULK_UPDATE: `bulk updated ${entity}s`,
IMPORT: `imported ${entity}s`,
}
return actionMap[action] || `${action.toLowerCase()} ${entity}`
}
function getActionIcon(action: string) {
switch (action) {
case 'CREATE': return <Plus className="h-3.5 w-3.5" />
case 'UPDATE': return <FileEdit className="h-3.5 w-3.5" />
case 'DELETE': return <Trash2 className="h-3.5 w-3.5" />
case 'LOGIN': return <LogIn className="h-3.5 w-3.5" />
case 'EXPORT': return <ArrowRight className="h-3.5 w-3.5" />
case 'SUBMIT': return <Send className="h-3.5 w-3.5" />
case 'ASSIGN': return <Users className="h-3.5 w-3.5" />
case 'INVITE': return <UserPlus className="h-3.5 w-3.5" />
default: return <Eye className="h-3.5 w-3.5" />
}
}
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
const { data, isLoading } = trpc.dashboard.getStats.useQuery(
{ editionId },
{ enabled: !!editionId }
)
if (isLoading) {
return <DashboardSkeleton />
}
if (!data) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Edition not found</p>
<p className="text-sm text-muted-foreground">
The selected edition could not be found
</p>
</CardContent>
</Card>
)
}
const {
edition,
activeRoundCount,
totalRoundCount,
projectCount,
newProjectsThisWeek,
totalJurors,
activeJurors,
evaluationStats,
totalAssignments,
recentRounds,
latestProjects,
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
pendingCOIs,
draftRounds,
unassignedProjects,
} = data
const submittedCount =
evaluationStats.find((e) => e.status === 'SUBMITTED')?._count || 0
const draftCount =
evaluationStats.find((e) => e.status === 'DRAFT')?._count || 0
const totalEvaluations = submittedCount + draftCount
const completionRate =
totalEvaluations > 0 ? (submittedCount / totalEvaluations) * 100 : 0
const invitedJurors = totalJurors - activeJurors
// Compute per-round eval stats
const roundsWithEvalStats = recentRounds.map((round) => {
const submitted = round.assignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
).length
const total = round._count.assignments
const percent = total > 0 ? Math.round((submitted / total) * 100) : 0
return { ...round, submittedEvals: submitted, totalEvals: total, evalPercent: percent }
})
// Upcoming deadlines from rounds
const now = new Date()
const deadlines: { label: string; roundName: string; date: Date }[] = []
for (const round of recentRounds) {
if (round.votingEndAt && new Date(round.votingEndAt) > now) {
deadlines.push({
label: 'Voting closes',
roundName: round.name,
date: new Date(round.votingEndAt),
})
}
if (round.submissionEndDate && new Date(round.submissionEndDate) > now) {
deadlines.push({
label: 'Submissions close',
roundName: round.name,
date: new Date(round.submissionEndDate),
})
}
}
deadlines.sort((a, b) => a.date.getTime() - b.date.getTime())
const upcomingDeadlines = deadlines.slice(0, 4)
// Category/issue bars
const categories = categoryBreakdown
.filter((c) => c.competitionCategory !== null)
.map((c) => ({
label: formatEnumLabel(c.competitionCategory!),
count: c._count,
}))
.sort((a, b) => b.count - a.count)
const issues = oceanIssueBreakdown
.filter((i) => i.oceanIssue !== null)
.map((i) => ({
label: formatEnumLabel(i.oceanIssue!),
count: i._count,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 5)
const maxCategoryCount = Math.max(...categories.map((c) => c.count), 1)
const maxIssueCount = Math.max(...issues.map((i) => i.count), 1)
return (
<>
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Welcome back, {sessionName} &mdash; {edition.name} {edition.year}
</p>
</div>
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Rounds</CardTitle>
<CircleDot className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalRoundCount}</div>
<p className="text-xs text-muted-foreground">
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
</p>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{projectCount}</div>
<p className="text-xs text-muted-foreground">
{newProjectsThisWeek > 0
? `${newProjectsThisWeek} new this week`
: 'In this edition'}
</p>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalJurors}</div>
<p className="text-xs text-muted-foreground">
{activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`}
</p>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{submittedCount}
{totalAssignments > 0 && (
<span className="text-sm font-normal text-muted-foreground">
{' '}/ {totalAssignments}
</span>
)}
</div>
<div className="mt-2">
<Progress value={completionRate} className="h-2" />
<p className="mt-1 text-xs text-muted-foreground">
{completionRate.toFixed(0)}% completion rate
</p>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* Quick Actions */}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/admin/rounds/new">
<Plus className="mr-1.5 h-3.5 w-3.5" />
New Round
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/projects/new">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Import Projects
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/members">
<UserPlus className="mr-1.5 h-3.5 w-3.5" />
Invite Jury
</Link>
</Button>
</div>
{/* Two-Column Content */}
<div className="grid gap-6 lg:grid-cols-12">
{/* Left Column */}
<div className="space-y-6 lg:col-span-7">
{/* Rounds Card (enhanced) */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Rounds</CardTitle>
<CardDescription>
Voting rounds in {edition.name}
</CardDescription>
</div>
<Link
href="/admin/rounds"
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
View all <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</CardHeader>
<CardContent>
{roundsWithEvalStats.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No rounds created yet
</p>
<Link
href="/admin/rounds/new"
className="mt-4 text-sm font-medium text-primary hover:underline"
>
Create your first round
</Link>
</div>
) : (
<div className="space-y-3">
{roundsWithEvalStats.map((round) => (
<Link
key={round.id}
href={`/admin/rounds/${round.id}`}
className="block"
>
<div className="rounded-lg border p-4 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1.5 flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">{round.name}</p>
<StatusBadge status={round.status} />
</div>
<p className="text-sm text-muted-foreground">
{round._count.projects} projects &middot; {round._count.assignments} assignments
{round.totalEvals > 0 && (
<> &middot; {round.evalPercent}% evaluated</>
)}
</p>
{round.votingStartAt && round.votingEndAt && (
<p className="text-xs text-muted-foreground">
Voting: {formatDateOnly(round.votingStartAt)} &ndash; {formatDateOnly(round.votingEndAt)}
</p>
)}
</div>
</div>
{round.totalEvals > 0 && (
<Progress value={round.evalPercent} className="mt-3 h-1.5" />
)}
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
{/* Latest Projects Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Latest Projects</CardTitle>
<CardDescription>Recently submitted projects</CardDescription>
</div>
<Link
href="/admin/projects"
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
View all <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</CardHeader>
<CardContent>
{latestProjects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No projects submitted yet
</p>
</div>
) : (
<div className="space-y-1">
{latestProjects.map((project) => (
<Link
key={project.id}
href={`/admin/projects/${project.id}`}
className="block"
>
<div className="flex items-start gap-3 rounded-lg p-3 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-sm">
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-sm leading-tight truncate">
{truncate(project.title, 45)}
</p>
<StatusBadge
status={project.status ?? 'SUBMITTED'}
size="sm"
className="shrink-0"
/>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{[
project.teamName,
project.country ? getCountryName(project.country) : null,
formatDateOnly(project.submittedAt || project.createdAt),
]
.filter(Boolean)
.join(' \u00b7 ')}
</p>
{(project.competitionCategory || project.oceanIssue) && (
<p className="text-xs text-muted-foreground/70 mt-0.5">
{[
project.competitionCategory
? formatEnumLabel(project.competitionCategory)
: null,
project.oceanIssue
? formatEnumLabel(project.oceanIssue)
: null,
]
.filter(Boolean)
.join(' \u00b7 ')}
</p>
)}
</div>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Right Column */}
<div className="space-y-6 lg:col-span-5">
{/* Pending Actions Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Pending Actions
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{pendingCOIs > 0 && (
<Link href="/admin/rounds" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-amber-500" />
<span className="text-sm">COI declarations to review</span>
</div>
<Badge variant="warning">{pendingCOIs}</Badge>
</Link>
)}
{unassignedProjects > 0 && (
<Link href="/admin/projects" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-orange-500" />
<span className="text-sm">Projects without assignments</span>
</div>
<Badge variant="warning">{unassignedProjects}</Badge>
</Link>
)}
{draftRounds > 0 && (
<Link href="/admin/rounds" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<CircleDot className="h-4 w-4 text-blue-500" />
<span className="text-sm">Draft rounds to activate</span>
</div>
<Badge variant="secondary">{draftRounds}</Badge>
</Link>
)}
{pendingCOIs === 0 && unassignedProjects === 0 && draftRounds === 0 && (
<div className="flex flex-col items-center py-4 text-center">
<CheckCircle2 className="h-6 w-6 text-emerald-500" />
<p className="mt-1.5 text-sm text-muted-foreground">All caught up!</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Evaluation Progress Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Evaluation Progress
</CardTitle>
</CardHeader>
<CardContent>
{roundsWithEvalStats.filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0).length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<TrendingUp className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No evaluations in progress
</p>
</div>
) : (
<div className="space-y-5">
{roundsWithEvalStats
.filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0)
.map((round) => (
<div key={round.id} className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium truncate">{round.name}</p>
<span className="text-sm font-semibold tabular-nums">
{round.evalPercent}%
</span>
</div>
<Progress value={round.evalPercent} className="h-2" />
<p className="text-xs text-muted-foreground">
{round.submittedEvals} of {round.totalEvals} evaluations submitted
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Category Breakdown Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layers className="h-4 w-4" />
Project Categories
</CardTitle>
</CardHeader>
<CardContent>
{categories.length === 0 && issues.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Layers className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No category data available
</p>
</div>
) : (
<div className="space-y-5">
{categories.length > 0 && (
<div className="space-y-2.5">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
By Type
</p>
{categories.map((cat) => (
<div key={cat.label} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{cat.label}</span>
<span className="font-medium tabular-nums">{cat.count}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${(cat.count / maxCategoryCount) * 100}%` }}
/>
</div>
</div>
))}
</div>
)}
{issues.length > 0 && (
<div className="space-y-2.5">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Top Issues
</p>
{issues.map((issue) => (
<div key={issue.label} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="truncate mr-2">{issue.label}</span>
<span className="font-medium tabular-nums">{issue.count}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-brand-teal transition-all"
style={{ width: `${(issue.count / maxIssueCount) * 100}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Recent Activity Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4" />
Recent Activity
</CardTitle>
</CardHeader>
<CardContent>
{recentActivity.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Activity className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No recent activity
</p>
</div>
) : (
<div className="space-y-3">
{recentActivity.map((log) => (
<div key={log.id} className="flex items-start gap-3">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
{getActionIcon(log.action)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm">
<span className="font-medium">{log.user?.name || 'System'}</span>
{' '}{formatAction(log.action, log.entityType)}
</p>
<p className="text-xs text-muted-foreground">
{formatRelativeTime(log.timestamp)}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Upcoming Deadlines Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Upcoming Deadlines
</CardTitle>
</CardHeader>
<CardContent>
{upcomingDeadlines.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Calendar className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No upcoming deadlines
</p>
</div>
) : (
<div className="space-y-4">
{upcomingDeadlines.map((deadline, i) => {
const days = daysUntil(deadline.date)
const isUrgent = days <= 7
return (
<div key={i} className="flex items-start gap-3">
<div className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${isUrgent ? 'bg-destructive/10' : 'bg-muted'}`}>
<Calendar className={`h-4 w-4 ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`} />
</div>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{deadline.label} &mdash; {deadline.roundName}
</p>
<p className={`text-xs ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`}>
{formatDateOnly(deadline.date)} &middot; in {days} day{days !== 1 ? 's' : ''}
</p>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Geographic Distribution (full width, at the bottom) */}
<GeographicSummaryCard programId={editionId} />
</>
)
}
function DashboardSkeleton() {
return (
<>
{/* Header skeleton */}
<div>
<Skeleton className="h-8 w-40" />
<Skeleton className="mt-2 h-4 w-64" />
</div>
{/* Stats grid skeleton */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-24" />
</CardContent>
</Card>
))}
</div>
{/* Two-column content skeleton */}
<div className="grid gap-6 lg:grid-cols-12">
<div className="space-y-6 lg:col-span-7">
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-52" />
</CardHeader>
<CardContent>
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-6 lg:col-span-5">
<Card>
<CardHeader><Skeleton className="h-6 w-40" /></CardHeader>
<CardContent>
<div className="space-y-4">
{[...Array(2)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader><Skeleton className="h-6 w-40" /></CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader><Skeleton className="h-6 w-40" /></CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(2)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
</div>
{/* Map skeleton */}
<Skeleton className="h-[450px] w-full rounded-lg" />
</>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -73,11 +73,15 @@ const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' |
} }
function ProjectDetailContent({ projectId }: { projectId: string }) { function ProjectDetailContent({ projectId }: { projectId: string }) {
// Fetch project data // Fetch project + assignments + stats in a single combined query
const { data: project, isLoading } = trpc.project.get.useQuery({ const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery({
id: projectId, id: projectId,
}) })
const project = fullDetail?.project
const assignments = fullDetail?.assignments
const stats = fullDetail?.stats
// Fetch files (flat list for backward compatibility) // Fetch files (flat list for backward compatibility)
const { data: files } = trpc.file.listByProject.useQuery({ projectId }) const { data: files } = trpc.file.listByProject.useQuery({ projectId })
@ -93,16 +97,6 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{ enabled: !!project?.programId } { enabled: !!project?.programId }
) )
// Fetch assignments
const { data: assignments } = trpc.assignment.listByProject.useQuery({
projectId,
})
// Fetch evaluation stats
const { data: stats } = trpc.evaluation.getProjectStats.useQuery({
projectId,
})
const utils = trpc.useUtils() const utils = trpc.useUtils()
if (isLoading) { if (isLoading) {

View File

@ -5,6 +5,7 @@ import Link from 'next/link'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertTriangle, RefreshCw, LayoutDashboard } from 'lucide-react' import { AlertTriangle, RefreshCw, LayoutDashboard } from 'lucide-react'
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
export default function AdminError({ export default function AdminError({
error, error,
@ -15,8 +16,14 @@ export default function AdminError({
}) { }) {
useEffect(() => { useEffect(() => {
console.error('Admin section error:', error) console.error('Admin section error:', error)
if (isChunkLoadError(error)) {
attemptChunkErrorRecovery('admin')
}
}, [error]) }, [error])
const isChunk = isChunkLoadError(error)
return ( return (
<div className="flex min-h-[50vh] items-center justify-center p-4"> <div className="flex min-h-[50vh] items-center justify-center p-4">
<Card className="max-w-md"> <Card className="max-w-md">
@ -28,10 +35,23 @@ export default function AdminError({
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-center"> <CardContent className="space-y-4 text-center">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
An error occurred while loading this admin page. Please try again or {isChunk
return to the dashboard. ? 'A new version of the platform may have been deployed. Please reload the page.'
: 'An error occurred while loading this admin page. Please try again or return to the dashboard.'}
</p> </p>
{!isChunk && (error.message || error.digest) && (
<p className="text-xs text-muted-foreground bg-muted rounded px-3 py-2 font-mono break-all">
{error.message || `Error ID: ${error.digest}`}
</p>
)}
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-2">
{isChunk ? (
<Button onClick={() => window.location.reload()}>
<RefreshCw className="mr-2 h-4 w-4" />
Reload Page
</Button>
) : (
<>
<Button onClick={reset} variant="outline"> <Button onClick={reset} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Try Again Try Again
@ -42,12 +62,9 @@ export default function AdminError({
Dashboard Dashboard
</Link> </Link>
</Button> </Button>
</div> </>
{error.digest && (
<p className="text-xs text-muted-foreground">
Error ID: {error.digest}
</p>
)} )}
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -0,0 +1,234 @@
'use client'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
import {
FileText,
Upload,
AlertTriangle,
Clock,
Video,
File,
Download,
} from 'lucide-react'
const fileTypeIcons: Record<string, typeof FileText> = {
EXEC_SUMMARY: FileText,
BUSINESS_PLAN: FileText,
PRESENTATION: FileText,
VIDEO_PITCH: Video,
VIDEO: Video,
OTHER: File,
SUPPORTING_DOC: File,
}
const fileTypeLabels: Record<string, string> = {
EXEC_SUMMARY: 'Executive Summary',
BUSINESS_PLAN: 'Business Plan',
PRESENTATION: 'Presentation',
VIDEO_PITCH: 'Video Pitch',
VIDEO: 'Video',
OTHER: 'Other Document',
SUPPORTING_DOC: 'Supporting Document',
}
export default function ApplicantDocumentsPage() {
const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
enabled: isAuthenticated,
})
if (isLoading) {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-64 w-full" />
<Skeleton className="h-48 w-full" />
</div>
)
}
if (!data?.project) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Documents</h1>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project</h2>
<p className="text-muted-foreground text-center">
Submit a project first to upload documents.
</p>
</CardContent>
</Card>
</div>
)
}
const { project, openRounds } = data
const isDraft = !project.submittedAt
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Upload className="h-6 w-6" />
Documents
</h1>
<p className="text-muted-foreground">
Upload and manage documents for your project: {project.title}
</p>
</div>
{/* Per-round upload sections */}
{openRounds.length > 0 && (
<div className="space-y-6">
{openRounds.map((round) => {
const now = new Date()
const isLate = round.votingStartAt && now > new Date(round.votingStartAt)
const hasDeadline = !!round.submissionDeadline
const deadlinePassed = hasDeadline && now > new Date(round.submissionDeadline!)
return (
<Card key={round.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">{round.name}</CardTitle>
<CardDescription>
Upload documents for this round
</CardDescription>
</div>
<div className="flex items-center gap-2">
{isLate && (
<Badge variant="warning" className="gap-1">
<AlertTriangle className="h-3 w-3" />
Late submission
</Badge>
)}
{hasDeadline && !deadlinePassed && (
<Badge variant="outline" className="gap-1">
<Clock className="h-3 w-3" />
Due {new Date(round.submissionDeadline!).toLocaleDateString()}
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent>
<RequirementUploadList
projectId={project.id}
roundId={round.id}
disabled={false}
/>
</CardContent>
</Card>
)
})}
</div>
)}
{/* Original round upload (if not already in openRounds) */}
{project.roundId && !openRounds.some((r) => r.id === project.roundId) && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
{project.round?.name || 'Submission Documents'}
</CardTitle>
<CardDescription>
Documents uploaded with your original application
</CardDescription>
</CardHeader>
<CardContent>
<RequirementUploadList
projectId={project.id}
roundId={project.roundId}
disabled={!isDraft}
/>
</CardContent>
</Card>
)}
{/* Uploaded files list */}
<Card>
<CardHeader>
<CardTitle>All Uploaded Documents</CardTitle>
<CardDescription>
All files associated with your project
</CardDescription>
</CardHeader>
<CardContent>
{project.files.length === 0 ? (
<p className="text-muted-foreground text-center py-4">
No documents uploaded yet
</p>
) : (
<div className="space-y-2">
{project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
return (
<div
key={file.id}
className="flex items-center justify-between p-3 rounded-lg border"
>
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<p className="font-medium text-sm">{file.fileName}</p>
{fileRecord.isLate && (
<Badge variant="warning" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" />
Late
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{fileTypeLabels[file.fileType] || file.fileType}
{' - '}
{new Date(file.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* No open rounds message */}
{openRounds.length === 0 && !project.roundId && (
<Card className="bg-muted/50">
<CardContent className="p-6 text-center">
<Clock className="h-10 w-10 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground">
No rounds are currently open for document submissions.
</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,138 @@
'use client'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { MentorChat } from '@/components/shared/mentor-chat'
import {
MessageSquare,
UserCircle,
FileText,
} from 'lucide-react'
export default function ApplicantMentorPage() {
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery(
undefined,
{ enabled: isAuthenticated }
)
const projectId = dashboardData?.project?.id
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
{ projectId: projectId! },
{ enabled: !!projectId }
)
const utils = trpc.useUtils()
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
onSuccess: () => {
utils.applicant.getMentorMessages.invalidate({ projectId: projectId! })
},
})
if (dashLoading) {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!projectId) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mentor</h1>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project</h2>
<p className="text-muted-foreground text-center">
Submit a project first to communicate with your mentor.
</p>
</CardContent>
</Card>
</div>
)
}
const mentor = dashboardData?.project?.mentorAssignment?.mentor
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<MessageSquare className="h-6 w-6" />
Mentor Communication
</h1>
<p className="text-muted-foreground">
Chat with your assigned mentor
</p>
</div>
{/* Mentor info */}
{mentor ? (
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<UserCircle className="h-10 w-10 text-muted-foreground" />
<div>
<p className="font-medium">{mentor.name || 'Mentor'}</p>
<p className="text-sm text-muted-foreground">{mentor.email}</p>
</div>
</div>
</CardContent>
</Card>
) : (
<Card className="bg-muted/50">
<CardContent className="flex flex-col items-center justify-center py-8">
<UserCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground text-center">
No mentor has been assigned to your project yet.
You&apos;ll be notified when a mentor is assigned.
</p>
</CardContent>
</Card>
)}
{/* Chat */}
{mentor && (
<Card>
<CardHeader>
<CardTitle>Messages</CardTitle>
<CardDescription>
Your conversation history with {mentor.name || 'your mentor'}
</CardDescription>
</CardHeader>
<CardContent>
<MentorChat
messages={mentorMessages || []}
currentUserId={session?.user?.id || ''}
onSendMessage={async (message) => {
await sendMessage.mutateAsync({ projectId: projectId!, message })
}}
isLoading={messagesLoading}
isSending={sendMessage.isPending}
/>
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,360 @@
'use client'
import { useSession } from 'next-auth/react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
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 {
FileText,
Calendar,
Clock,
AlertCircle,
CheckCircle,
Users,
Crown,
MessageSquare,
Upload,
ArrowRight,
} from 'lucide-react'
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
DRAFT: 'secondary',
SUBMITTED: 'default',
UNDER_REVIEW: 'default',
ELIGIBLE: 'default',
SEMIFINALIST: 'success',
FINALIST: 'success',
WINNER: 'success',
REJECTED: 'destructive',
}
export default function ApplicantDashboardPage() {
const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
enabled: isAuthenticated,
})
if (sessionStatus === 'loading' || isLoading) {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96" />
</div>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-32 w-full" />
</div>
<div className="space-y-6">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-48 w-full" />
</div>
</div>
</div>
)
}
// No project yet
if (!data?.project) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">My Project</h1>
<p className="text-muted-foreground">
Your applicant dashboard
</p>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project Yet</h2>
<p className="text-muted-foreground text-center max-w-md">
You haven&apos;t submitted a project yet. Check for open application rounds
on the MOPC website.
</p>
</CardContent>
</Card>
</div>
)
}
const { project, timeline, currentStatus, openRounds } = data
const isDraft = !project.submittedAt
const programYear = project.round?.program?.year
const roundName = project.round?.name
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
{currentStatus && (
<Badge variant={statusColors[currentStatus] || 'secondary'}>
{currentStatus.replace('_', ' ')}
</Badge>
)}
</div>
<p className="text-muted-foreground">
{programYear ? `${programYear} Edition` : ''}{roundName ? ` - ${roundName}` : ''}
</p>
</div>
</div>
{/* Draft warning */}
{isDraft && (
<Alert>
<Clock className="h-4 w-4" />
<AlertTitle>Draft Submission</AlertTitle>
<AlertDescription>
This submission has not been submitted yet. You can continue editing and submit when ready.
</AlertDescription>
</Alert>
)}
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Project details */}
<Card>
<CardHeader>
<CardTitle>Project Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{project.teamName && (
<div>
<p className="text-sm font-medium text-muted-foreground">Team/Organization</p>
<p>{project.teamName}</p>
</div>
)}
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground">Description</p>
<p className="whitespace-pre-wrap">{project.description}</p>
</div>
)}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
)}
{/* Metadata */}
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
<div className="border-t pt-4 mt-4">
<p className="text-sm font-medium text-muted-foreground mb-3">Additional Information</p>
<dl className="space-y-2">
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
<div key={key} className="flex justify-between">
<dt className="text-sm text-muted-foreground capitalize">
{key.replace(/_/g, ' ')}
</dt>
<dd className="text-sm font-medium">{String(value)}</dd>
</div>
))}
</dl>
</div>
)}
{/* Meta info row */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground border-t pt-4 mt-4">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Created {new Date(project.createdAt).toLocaleDateString()}
</div>
{project.submittedAt ? (
<div className="flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-green-500" />
Submitted {new Date(project.submittedAt).toLocaleDateString()}
</div>
) : (
<div className="flex items-center gap-1">
<Clock className="h-4 w-4 text-orange-500" />
Draft
</div>
)}
<div className="flex items-center gap-1">
<FileText className="h-4 w-4" />
{project.files.length} file(s)
</div>
</div>
</CardContent>
</Card>
{/* Quick actions */}
<div className="grid gap-4 sm:grid-cols-3">
<Card className="hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<Link href={"/applicant/documents" as Route} className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Documents</p>
<p className="text-xs text-muted-foreground">
{openRounds.length > 0 ? `${openRounds.length} round(s) open` : 'View uploads'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
</CardContent>
</Card>
<Card className="hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<Link href={"/applicant/team" as Route} className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-purple-100 dark:bg-purple-900/30">
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Team</p>
<p className="text-xs text-muted-foreground">
{project.teamMembers.length} member(s)
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
</CardContent>
</Card>
<Card className="hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<Link href={"/applicant/mentor" as Route} className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Mentor</p>
<p className="text-xs text-muted-foreground">
{project.mentorAssignment?.mentor?.name || 'Not assigned'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
</CardContent>
</Card>
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Status timeline */}
<Card>
<CardHeader>
<CardTitle>Status Timeline</CardTitle>
</CardHeader>
<CardContent>
<StatusTracker
timeline={timeline}
currentStatus={currentStatus || 'SUBMITTED'}
/>
</CardContent>
</Card>
{/* Team overview */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Team
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={"/applicant/team" as Route}>
Manage
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{project.teamMembers.length > 0 ? (
project.teamMembers.slice(0, 5).map((member) => (
<div key={member.id} className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-4 w-4 text-yellow-500" />
) : (
<span className="text-xs font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{member.user.name || member.user.email}
</p>
<p className="text-xs text-muted-foreground">
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</p>
</div>
</div>
))
) : (
<p className="text-sm text-muted-foreground text-center py-2">
No team members yet
</p>
)}
{project.teamMembers.length > 5 && (
<p className="text-xs text-muted-foreground text-center">
+{project.teamMembers.length - 5} more
</p>
)}
</CardContent>
</Card>
{/* Key dates */}
<Card>
<CardHeader>
<CardTitle>Key Dates</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Created</span>
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
</div>
{project.submittedAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Submitted</span>
<span>{new Date(project.submittedAt).toLocaleDateString()}</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last Updated</span>
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
</div>
{project.round?.submissionDeadline && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Deadline</span>
<span>{new Date(project.round.submissionDeadline).toLocaleDateString()}</span>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,437 @@
'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Users,
UserPlus,
Crown,
Mail,
Trash2,
Loader2,
AlertCircle,
CheckCircle,
Clock,
FileText,
} from 'lucide-react'
const inviteSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
role: z.enum(['MEMBER', 'ADVISOR']),
title: z.string().optional(),
})
type InviteFormData = z.infer<typeof inviteSchema>
const roleLabels: Record<string, string> = {
LEAD: 'Team Lead',
MEMBER: 'Team Member',
ADVISOR: 'Advisor',
}
const statusLabels: Record<string, { label: string; icon: React.ComponentType<{ className?: string }> }> = {
ACTIVE: { label: 'Active', icon: CheckCircle },
INVITED: { label: 'Pending', icon: Clock },
SUSPENDED: { label: 'Suspended', icon: AlertCircle },
}
export default function ApplicantTeamPage() {
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const [isInviteOpen, setIsInviteOpen] = useState(false)
const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery(
undefined,
{ enabled: isAuthenticated }
)
const projectId = dashboardData?.project?.id
const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
{ projectId: projectId! },
{ enabled: !!projectId }
)
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member invited!')
setIsInviteOpen(false)
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const removeMutation = trpc.applicant.removeTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member removed')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const form = useForm<InviteFormData>({
resolver: zodResolver(inviteSchema),
defaultValues: {
name: '',
email: '',
role: 'MEMBER',
title: '',
},
})
const onInvite = async (data: InviteFormData) => {
if (!projectId) return
await inviteMutation.mutateAsync({
projectId,
...data,
})
form.reset()
}
const isLoading = dashLoading || teamLoading
if (isLoading) {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardContent className="p-6 space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<Skeleton className="h-8 w-20" />
</div>
))}
</CardContent>
</Card>
</div>
)
}
if (!projectId) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Team</h1>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project</h2>
<p className="text-muted-foreground text-center">
Submit a project first to manage your team.
</p>
</CardContent>
</Card>
</div>
)
}
// Check if user is team lead
const currentUserMember = teamData?.teamMembers.find(
(tm) => tm.userId === session?.user?.id
)
const isTeamLead =
currentUserMember?.role === 'LEAD' ||
teamData?.submittedBy?.id === session?.user?.id
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Users className="h-6 w-6" />
Team Members
</h1>
<p className="text-muted-foreground">
Manage your project team
</p>
</div>
{isTeamLead && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
Invite Member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your project team. They will receive an email
with instructions to create their account.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="Jane Doe"
{...form.register('name')}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="jane@example.com"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={form.watch('role')}
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Team Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
placeholder="CTO, Designer..."
{...form.register('title')}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsInviteOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={inviteMutation.isPending}>
{inviteMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send Invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
{/* Team Members List */}
<Card>
<CardHeader>
<CardTitle>Team ({teamData?.teamMembers.length || 0} members)</CardTitle>
<CardDescription>
Everyone on this list can view and collaborate on this project.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{teamData?.teamMembers.map((member) => {
const StatusIcon = statusLabels[member.user.status]?.icon || AlertCircle
return (
<div
key={member.id}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-5 w-5 text-yellow-500" />
) : (
<span className="text-sm font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{member.user.name}</span>
<Badge variant="outline" className="text-xs">
{roleLabels[member.role] || member.role}
</Badge>
{member.title && (
<span className="text-xs text-muted-foreground">
({member.title})
</span>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
{member.user.email}
<StatusIcon className="h-3 w-3 ml-2" />
<span className="text-xs">
{statusLabels[member.user.status]?.label || member.user.status}
</span>
</div>
</div>
</div>
{isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Team Member</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove {member.user.name} from the team?
They will no longer have access to this project.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => removeMutation.mutate({ projectId, userId: member.userId })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
})}
{(!teamData?.teamMembers || teamData.teamMembers.length === 0) && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Users className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No team members yet.</p>
{isTeamLead && (
<Button
variant="outline"
className="mt-4"
onClick={() => setIsInviteOpen(true)}
>
<UserPlus className="mr-2 h-4 w-4" />
Invite Your First Team Member
</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* Team Documents */}
{teamData?.roundId && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Team Documents</CardTitle>
<CardDescription>
Upload required documents for your project. Any team member can upload files.
</CardDescription>
</CardHeader>
<CardContent>
<RequirementUploadList
projectId={projectId}
roundId={teamData.roundId}
/>
</CardContent>
</Card>
)}
{/* Info Card */}
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">About Team Access</p>
<p className="mt-1">
All team members can view project details and status updates.
Only the team lead can invite or remove team members.
Invited members will receive an email to set up their account.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,33 @@
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { ApplicantNav } from '@/components/layouts/applicant-nav'
export const dynamic = 'force-dynamic'
export default async function ApplicantLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
if (session.user.role !== 'APPLICANT') {
redirect('/login')
}
return (
<div className="min-h-screen bg-background">
<ApplicantNav
user={{
name: session.user.name,
email: session.user.email,
}}
/>
<main className="container-app py-6 lg:py-8">{children}</main>
</div>
)
}

View File

@ -5,6 +5,7 @@ import Link from 'next/link'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertTriangle, RefreshCw, ClipboardList } from 'lucide-react' import { AlertTriangle, RefreshCw, ClipboardList } from 'lucide-react'
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
export default function JuryError({ export default function JuryError({
error, error,
@ -15,8 +16,14 @@ export default function JuryError({
}) { }) {
useEffect(() => { useEffect(() => {
console.error('Jury section error:', error) console.error('Jury section error:', error)
if (isChunkLoadError(error)) {
attemptChunkErrorRecovery('jury')
}
}, [error]) }, [error])
const isChunk = isChunkLoadError(error)
return ( return (
<div className="flex min-h-[50vh] items-center justify-center p-4"> <div className="flex min-h-[50vh] items-center justify-center p-4">
<Card className="max-w-md"> <Card className="max-w-md">
@ -28,10 +35,18 @@ export default function JuryError({
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-center"> <CardContent className="space-y-4 text-center">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
An error occurred while loading this page. Please try again or {isChunk
return to your assignments. ? 'A new version of the platform may have been deployed. Please reload the page.'
: 'An error occurred while loading this page. Please try again or return to your assignments.'}
</p> </p>
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-2">
{isChunk ? (
<Button onClick={() => window.location.reload()}>
<RefreshCw className="mr-2 h-4 w-4" />
Reload Page
</Button>
) : (
<>
<Button onClick={reset} variant="outline"> <Button onClick={reset} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Try Again Try Again
@ -42,8 +57,10 @@ export default function JuryError({
My Assignments My Assignments
</Link> </Link>
</Button> </Button>
</>
)}
</div> </div>
{error.digest && ( {!isChunk && error.digest && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Error ID: {error.digest} Error ID: {error.digest}
</p> </p>

View File

@ -47,8 +47,9 @@ async function JuryDashboardContent() {
return null return null
} }
// Get all assignments for this jury member // Get assignments and grace periods in parallel
const assignments = await prisma.assignment.findMany({ const [assignments, gracePeriods] = await Promise.all([
prisma.assignment.findMany({
where: { userId }, where: { userId },
include: { include: {
project: { project: {
@ -90,7 +91,18 @@ async function JuryDashboardContent() {
{ round: { votingEndAt: 'asc' } }, { round: { votingEndAt: 'asc' } },
{ createdAt: 'asc' }, { createdAt: 'asc' },
], ],
}) }),
prisma.gracePeriod.findMany({
where: {
userId,
extendedUntil: { gte: new Date() },
},
select: {
roundId: true,
extendedUntil: true,
},
}),
])
// Calculate stats // Calculate stats
const totalAssignments = assignments.length const totalAssignments = assignments.length
@ -122,18 +134,6 @@ async function JuryDashboardContent() {
{} as Record<string, { round: (typeof assignments)[0]['round']; assignments: typeof assignments }> {} as Record<string, { round: (typeof assignments)[0]['round']; assignments: typeof assignments }>
) )
// Get grace periods for this user
const gracePeriods = await prisma.gracePeriod.findMany({
where: {
userId,
extendedUntil: { gte: new Date() },
},
select: {
roundId: true,
extendedUntil: true,
},
})
const graceByRound = new Map<string, Date>() const graceByRound = new Map<string, Date>()
for (const gp of gracePeriods) { for (const gp of gracePeriods) {
const existing = graceByRound.get(gp.roundId) const existing = graceByRound.get(gp.roundId)

View File

@ -6,6 +6,7 @@ import type { Route } from 'next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertTriangle, RefreshCw, Users } from 'lucide-react' import { AlertTriangle, RefreshCw, Users } from 'lucide-react'
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
export default function MentorError({ export default function MentorError({
error, error,
@ -16,8 +17,14 @@ export default function MentorError({
}) { }) {
useEffect(() => { useEffect(() => {
console.error('Mentor section error:', error) console.error('Mentor section error:', error)
if (isChunkLoadError(error)) {
attemptChunkErrorRecovery('mentor')
}
}, [error]) }, [error])
const isChunk = isChunkLoadError(error)
return ( return (
<div className="flex min-h-[50vh] items-center justify-center p-4"> <div className="flex min-h-[50vh] items-center justify-center p-4">
<Card className="max-w-md"> <Card className="max-w-md">
@ -29,10 +36,18 @@ export default function MentorError({
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-center"> <CardContent className="space-y-4 text-center">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
An error occurred while loading this page. Please try again or {isChunk
return to your mentee dashboard. ? 'A new version of the platform may have been deployed. Please reload the page.'
: 'An error occurred while loading this page. Please try again or return to your mentee dashboard.'}
</p> </p>
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-2">
{isChunk ? (
<Button onClick={() => window.location.reload()}>
<RefreshCw className="mr-2 h-4 w-4" />
Reload Page
</Button>
) : (
<>
<Button onClick={reset} variant="outline"> <Button onClick={reset} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Try Again Try Again
@ -43,8 +58,10 @@ export default function MentorError({
Dashboard Dashboard
</Link> </Link>
</Button> </Button>
</>
)}
</div> </div>
{error.digest && ( {!isChunk && error.digest && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Error ID: {error.digest} Error ID: {error.digest}
</p> </p>

View File

@ -5,6 +5,7 @@ import Link from 'next/link'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertTriangle, RefreshCw, Eye } from 'lucide-react' import { AlertTriangle, RefreshCw, Eye } from 'lucide-react'
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
export default function ObserverError({ export default function ObserverError({
error, error,
@ -15,8 +16,14 @@ export default function ObserverError({
}) { }) {
useEffect(() => { useEffect(() => {
console.error('Observer section error:', error) console.error('Observer section error:', error)
if (isChunkLoadError(error)) {
attemptChunkErrorRecovery('observer')
}
}, [error]) }, [error])
const isChunk = isChunkLoadError(error)
return ( return (
<div className="flex min-h-[50vh] items-center justify-center p-4"> <div className="flex min-h-[50vh] items-center justify-center p-4">
<Card className="max-w-md"> <Card className="max-w-md">
@ -28,10 +35,18 @@ export default function ObserverError({
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-center"> <CardContent className="space-y-4 text-center">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
An error occurred while loading this page. Please try again or {isChunk
return to the observer dashboard. ? 'A new version of the platform may have been deployed. Please reload the page.'
: 'An error occurred while loading this page. Please try again or return to the observer dashboard.'}
</p> </p>
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-2">
{isChunk ? (
<Button onClick={() => window.location.reload()}>
<RefreshCw className="mr-2 h-4 w-4" />
Reload Page
</Button>
) : (
<>
<Button onClick={reset} variant="outline"> <Button onClick={reset} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Try Again Try Again
@ -42,8 +57,10 @@ export default function ObserverError({
Dashboard Dashboard
</Link> </Link>
</Button> </Button>
</>
)}
</div> </div>
{error.digest && ( {!isChunk && error.digest && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Error ID: {error.digest} Error ID: {error.digest}
</p> </p>

View File

@ -5,6 +5,7 @@ import Link from 'next/link'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertTriangle, RefreshCw, Home } from 'lucide-react' import { AlertTriangle, RefreshCw, Home } from 'lucide-react'
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
export default function PublicError({ export default function PublicError({
error, error,
@ -15,8 +16,14 @@ export default function PublicError({
}) { }) {
useEffect(() => { useEffect(() => {
console.error('Public section error:', error) console.error('Public section error:', error)
if (isChunkLoadError(error)) {
attemptChunkErrorRecovery('public')
}
}, [error]) }, [error])
const isChunk = isChunkLoadError(error)
return ( return (
<div className="flex min-h-[50vh] items-center justify-center p-4"> <div className="flex min-h-[50vh] items-center justify-center p-4">
<Card className="max-w-md"> <Card className="max-w-md">
@ -28,10 +35,18 @@ export default function PublicError({
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-center"> <CardContent className="space-y-4 text-center">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
An error occurred while loading this page. Please try again or {isChunk
return to the home page. ? 'A new version of the platform may have been deployed. Please reload the page.'
: 'An error occurred while loading this page. Please try again or return to the home page.'}
</p> </p>
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-2">
{isChunk ? (
<Button onClick={() => window.location.reload()}>
<RefreshCw className="mr-2 h-4 w-4" />
Reload Page
</Button>
) : (
<>
<Button onClick={reset} variant="outline"> <Button onClick={reset} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Try Again Try Again
@ -42,8 +57,10 @@ export default function PublicError({
Home Home
</Link> </Link>
</Button> </Button>
</>
)}
</div> </div>
{error.digest && ( {!isChunk && error.digest && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Error ID: {error.digest} Error ID: {error.digest}
</p> </p>

View File

@ -7,6 +7,7 @@ const ROLE_DASHBOARDS: Record<string, string> = {
JURY_MEMBER: '/jury', JURY_MEMBER: '/jury',
MENTOR: '/mentor', MENTOR: '/mentor',
OBSERVER: '/observer', OBSERVER: '/observer',
APPLICANT: '/applicant',
} }
export default async function SettingsLayout({ export default async function SettingsLayout({

View File

@ -3,7 +3,8 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { AlertTriangle } from 'lucide-react' import { AlertTriangle, RefreshCw } from 'lucide-react'
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
export default function Error({ export default function Error({
error, error,
@ -14,8 +15,14 @@ export default function Error({
}) { }) {
useEffect(() => { useEffect(() => {
console.error('Application error:', error) console.error('Application error:', error)
if (isChunkLoadError(error)) {
attemptChunkErrorRecovery('root')
}
}, [error]) }, [error])
const isChunk = isChunkLoadError(error)
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center px-4 py-16 text-center"> <div className="flex min-h-screen flex-col items-center justify-center px-4 py-16 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10"> <div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
@ -25,21 +32,31 @@ export default function Error({
Something went wrong Something went wrong
</h1> </h1>
<p className="mt-4 max-w-md text-body text-muted-foreground"> <p className="mt-4 max-w-md text-body text-muted-foreground">
An unexpected error occurred. Please try again or return to the {isChunk
dashboard. ? 'A new version of the platform may have been deployed. Please reload the page.'
: 'An unexpected error occurred. Please try again or return to the dashboard.'}
</p> </p>
{error.digest && ( {!isChunk && error.digest && (
<p className="mt-2 text-tiny text-muted-foreground/60"> <p className="mt-2 text-tiny text-muted-foreground/60">
Error ID: {error.digest} Error ID: {error.digest}
</p> </p>
)} )}
<div className="mt-8 flex gap-4"> <div className="mt-8 flex gap-4">
{isChunk ? (
<Button size="lg" onClick={() => window.location.reload()}>
<RefreshCw className="mr-2 h-4 w-4" />
Reload Page
</Button>
) : (
<>
<Button size="lg" onClick={() => reset()}> <Button size="lg" onClick={() => reset()}>
Try Again Try Again
</Button> </Button>
<Button variant="outline" size="lg" asChild> <Button variant="outline" size="lg" asChild>
<Link href="/">Return to Dashboard</Link> <Link href="/">Return to Dashboard</Link>
</Button> </Button>
</>
)}
</div> </div>
</div> </div>
) )

View File

@ -24,7 +24,7 @@ export default async function HomePage() {
} else if (session.user.role === 'OBSERVER') { } else if (session.user.role === 'OBSERVER') {
redirect('/observer') redirect('/observer')
} else if (session.user.role === 'APPLICANT') { } else if (session.user.role === 'APPLICANT') {
redirect('/my-submission' as Route) redirect('/applicant' as Route)
} }
} }

View File

@ -12,7 +12,7 @@ function makeQueryClient() {
return new QueryClient({ return new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 60 * 1000, // 1 minute staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}, },
}, },

View File

@ -41,6 +41,7 @@ import { EditionSelector } from '@/components/shared/edition-selector'
import { useEdition } from '@/contexts/edition-context' import { useEdition } from '@/contexts/edition-context'
import { UserAvatar } from '@/components/shared/user-avatar' import { UserAvatar } from '@/components/shared/user-avatar'
import { NotificationBell } from '@/components/shared/notification-bell' import { NotificationBell } from '@/components/shared/notification-bell'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
interface AdminSidebarProps { interface AdminSidebarProps {
@ -145,7 +146,11 @@ const roleLabels: Record<string, string> = {
export function AdminSidebar({ user }: AdminSidebarProps) { export function AdminSidebar({ user }: AdminSidebarProps) {
const pathname = usePathname() const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery() const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated,
})
const { currentEdition } = useEdition() const { currentEdition } = useEdition()
const isSuperAdmin = user.role === 'SUPER_ADMIN' const isSuperAdmin = user.role === 'SUPER_ADMIN'

View File

@ -0,0 +1,42 @@
'use client'
import { Home, Users, FileText, MessageSquare } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/applicant',
icon: Home,
},
{
name: 'Team',
href: '/applicant/team',
icon: Users,
},
{
name: 'Documents',
href: '/applicant/documents',
icon: FileText,
},
{
name: 'Mentor',
href: '/applicant/mentor',
icon: MessageSquare,
},
]
interface ApplicantNavProps {
user: RoleNavUser
}
export function ApplicantNav({ user }: ApplicantNavProps) {
return (
<RoleNav
navigation={navigation}
roleName="Applicant"
user={user}
basePath="/applicant"
/>
)
}

View File

@ -3,7 +3,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react' import { signOut, useSession } from 'next-auth/react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { UserAvatar } from '@/components/shared/user-avatar' import { UserAvatar } from '@/components/shared/user-avatar'
@ -50,7 +50,11 @@ function isNavItemActive(pathname: string, href: string, basePath: string): bool
export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: RoleNavProps) { export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: RoleNavProps) {
const pathname = usePathname() const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery() const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated,
})
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), []) useEffect(() => setMounted(true), [])

View File

@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { cn, formatRelativeTime } from '@/lib/utils' import { cn, formatRelativeTime } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -212,6 +213,8 @@ function NotificationItem({
export function NotificationBell() { export function NotificationBell() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const pathname = usePathname() const pathname = usePathname()
const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
// Derive the role-based path prefix from the current route // Derive the role-based path prefix from the current route
const pathPrefix = pathname.startsWith('/admin') const pathPrefix = pathname.startsWith('/admin')
@ -222,16 +225,20 @@ export function NotificationBell() {
? '/mentor' ? '/mentor'
: pathname.startsWith('/observer') : pathname.startsWith('/observer')
? '/observer' ? '/observer'
: pathname.startsWith('/applicant')
? '/applicant'
: '' : ''
const { data: countData } = trpc.notification.getUnreadCount.useQuery( const { data: countData } = trpc.notification.getUnreadCount.useQuery(
undefined, undefined,
{ {
enabled: isAuthenticated,
refetchInterval: 30000, // Refetch every 30 seconds refetchInterval: 30000, // Refetch every 30 seconds
} }
) )
const { data: hasUrgent } = trpc.notification.hasUrgent.useQuery(undefined, { const { data: hasUrgent } = trpc.notification.hasUrgent.useQuery(undefined, {
enabled: isAuthenticated,
refetchInterval: 30000, refetchInterval: 30000,
}) })
@ -241,7 +248,7 @@ export function NotificationBell() {
limit: 20, limit: 20,
}, },
{ {
enabled: open, // Only fetch when popover is open enabled: open && isAuthenticated, // Only fetch when popover is open and authenticated
} }
) )

View File

@ -9,6 +9,7 @@ const ROLE_DASHBOARDS: Record<string, string> = {
JURY_MEMBER: '/jury', JURY_MEMBER: '/jury',
MENTOR: '/mentor', MENTOR: '/mentor',
OBSERVER: '/observer', OBSERVER: '/observer',
APPLICANT: '/applicant',
} }
export async function requireRole(...allowedRoles: UserRole[]) { export async function requireRole(...allowedRoles: UserRole[]) {

View File

@ -0,0 +1,34 @@
/**
* Detects ChunkLoadError (caused by stale builds or deployment mismatches)
* and auto-reloads the page once to recover.
*/
export function isChunkLoadError(error: Error): boolean {
return (
error.name === 'ChunkLoadError' ||
error.message?.includes('Loading chunk') ||
error.message?.includes('Failed to fetch dynamically imported module') ||
error.message?.includes('error loading dynamically imported module')
)
}
/**
* Attempts auto-reload recovery for ChunkLoadError.
* Uses sessionStorage to prevent infinite reload loops (max once per 30s).
* Returns true if a reload was triggered.
*/
export function attemptChunkErrorRecovery(sectionKey: string): boolean {
if (typeof window === 'undefined') return false
const reloadKey = `chunk-reload-${sectionKey}`
const lastReload = sessionStorage.getItem(reloadKey)
const now = Date.now()
// Only auto-reload if we haven't reloaded in the last 30 seconds
if (!lastReload || now - parseInt(lastReload) > 30000) {
sessionStorage.setItem(reloadKey, String(now))
window.location.reload()
return true
}
return false
}

View File

@ -4,9 +4,19 @@ const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined prisma: PrismaClient | undefined
} }
function getDatasourceUrl(): string | undefined {
const url = process.env.DATABASE_URL
if (!url) return undefined
// Append connection pool params if not already present
if (url.includes('connection_limit')) return url
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}connection_limit=20&pool_timeout=10`
}
export const prisma = export const prisma =
globalForPrisma.prisma ?? globalForPrisma.prisma ??
new PrismaClient({ new PrismaClient({
datasourceUrl: getDatasourceUrl(),
log: log:
process.env.NODE_ENV === 'development' process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn'] ? ['query', 'error', 'warn']

View File

@ -35,6 +35,7 @@ import { messageRouter } from './message'
import { webhookRouter } from './webhook' import { webhookRouter } from './webhook'
import { projectPoolRouter } from './project-pool' import { projectPoolRouter } from './project-pool'
import { wizardTemplateRouter } from './wizard-template' import { wizardTemplateRouter } from './wizard-template'
import { dashboardRouter } from './dashboard'
/** /**
* Root tRPC router that combines all domain routers * Root tRPC router that combines all domain routers
@ -76,6 +77,7 @@ export const appRouter = router({
webhook: webhookRouter, webhook: webhookRouter,
projectPool: projectPoolRouter, projectPool: projectPoolRouter,
wizardTemplate: wizardTemplateRouter, wizardTemplate: wizardTemplateRouter,
dashboard: dashboardRouter,
}) })
export type AppRouter = typeof appRouter export type AppRouter = typeof appRouter

View File

@ -1030,4 +1030,186 @@ export const applicantRouter = router({
return messages return messages
}), }),
/**
* Get the applicant's dashboard data: their project (latest edition),
* team members, open rounds for document submission, and status timeline.
*/
getMyDashboard: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only applicants can access this',
})
}
// Find the applicant's project (most recent, from active edition if possible)
const project = await ctx.prisma.project.findFirst({
where: {
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
include: {
round: {
include: {
program: { select: { id: true, name: true, year: true, status: true } },
},
},
files: {
orderBy: { createdAt: 'desc' },
},
teamMembers: {
include: {
user: {
select: { id: true, name: true, email: true, status: true },
},
},
orderBy: { joinedAt: 'asc' },
},
submittedBy: {
select: { id: true, name: true, email: true },
},
mentorAssignment: {
include: {
mentor: {
select: { id: true, name: true, email: true },
},
},
},
wonAwards: {
select: { id: true, name: true },
},
},
orderBy: { createdAt: 'desc' },
})
if (!project) {
return { project: null, openRounds: [], timeline: [], currentStatus: null }
}
const currentStatus = project.status ?? 'SUBMITTED'
// Fetch status history
const statusHistory = await ctx.prisma.projectStatusHistory.findMany({
where: { projectId: project.id },
orderBy: { changedAt: 'asc' },
select: { status: true, changedAt: true },
})
const statusDateMap = new Map<string, Date>()
for (const entry of statusHistory) {
if (!statusDateMap.has(entry.status)) {
statusDateMap.set(entry.status, entry.changedAt)
}
}
const isRejected = currentStatus === 'REJECTED'
const hasWonAward = project.wonAwards.length > 0
// Build timeline
const timeline = [
{
status: 'CREATED',
label: 'Application Started',
date: project.createdAt,
completed: true,
isTerminal: false,
},
{
status: 'SUBMITTED',
label: 'Application Submitted',
date: project.submittedAt || statusDateMap.get('SUBMITTED') || null,
completed: !!project.submittedAt || statusDateMap.has('SUBMITTED'),
isTerminal: false,
},
{
status: 'UNDER_REVIEW',
label: 'Under Review',
date: statusDateMap.get('ELIGIBLE') || statusDateMap.get('ASSIGNED') ||
(currentStatus !== 'SUBMITTED' && project.submittedAt ? project.submittedAt : null),
completed: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(currentStatus),
isTerminal: false,
},
]
if (isRejected) {
timeline.push({
status: 'REJECTED',
label: 'Not Selected',
date: statusDateMap.get('REJECTED') || null,
completed: true,
isTerminal: true,
})
} else {
timeline.push(
{
status: 'SEMIFINALIST',
label: 'Semi-finalist',
date: statusDateMap.get('SEMIFINALIST') || null,
completed: ['SEMIFINALIST', 'FINALIST'].includes(currentStatus) || hasWonAward,
isTerminal: false,
},
{
status: 'FINALIST',
label: 'Finalist',
date: statusDateMap.get('FINALIST') || null,
completed: currentStatus === 'FINALIST' || hasWonAward,
isTerminal: false,
},
)
if (hasWonAward) {
timeline.push({
status: 'WINNER',
label: `Winner${project.wonAwards.length > 0 ? ` - ${project.wonAwards[0].name}` : ''}`,
date: null,
completed: true,
isTerminal: false,
})
}
}
// Find open rounds in the same program where documents can be submitted
const programId = project.round?.programId || project.programId
const now = new Date()
const openRounds = programId
? await ctx.prisma.round.findMany({
where: {
programId,
status: 'ACTIVE',
},
orderBy: { sortOrder: 'asc' },
})
: []
// Filter: only rounds that still accept uploads
const uploadableRounds = openRounds.filter((round) => {
const settings = round.settingsJson as Record<string, unknown> | null
const uploadPolicy = settings?.uploadDeadlinePolicy as string | undefined
const roundStarted = round.votingStartAt && now > round.votingStartAt
// If deadline passed and policy is BLOCK, skip
if (roundStarted && uploadPolicy === 'BLOCK') return false
return true
})
// Determine user's role in the project
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
const isTeamLead = project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD'
return {
project: {
...project,
isTeamLead,
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
},
openRounds: uploadableRounds,
timeline,
currentStatus,
}
}),
}) })

View File

@ -0,0 +1,184 @@
import { z } from 'zod'
import { router, adminProcedure } from '../trpc'
export const dashboardRouter = router({
/**
* Get all dashboard stats in a single query batch.
* Replaces the 16 parallel Prisma queries that were previously
* run during SSR, which blocked the event loop and caused 503s.
*/
getStats: adminProcedure
.input(z.object({ editionId: z.string() }))
.query(async ({ ctx, input }) => {
const { editionId } = input
const edition = await ctx.prisma.program.findUnique({
where: { id: editionId },
select: { name: true, year: true },
})
if (!edition) return null
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
const [
activeRoundCount,
totalRoundCount,
projectCount,
newProjectsThisWeek,
totalJurors,
activeJurors,
evaluationStats,
totalAssignments,
recentRounds,
latestProjects,
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
pendingCOIs,
draftRounds,
unassignedProjects,
] = await Promise.all([
ctx.prisma.round.count({
where: { programId: editionId, status: 'ACTIVE' },
}),
ctx.prisma.round.count({
where: { programId: editionId },
}),
ctx.prisma.project.count({
where: { programId: editionId },
}),
ctx.prisma.project.count({
where: {
programId: editionId,
createdAt: { gte: sevenDaysAgo },
},
}),
ctx.prisma.user.count({
where: {
role: 'JURY_MEMBER',
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
assignments: { some: { round: { programId: editionId } } },
},
}),
ctx.prisma.user.count({
where: {
role: 'JURY_MEMBER',
status: 'ACTIVE',
assignments: { some: { round: { programId: editionId } } },
},
}),
ctx.prisma.evaluation.groupBy({
by: ['status'],
where: { assignment: { round: { programId: editionId } } },
_count: true,
}),
ctx.prisma.assignment.count({
where: { round: { programId: editionId } },
}),
ctx.prisma.round.findMany({
where: { programId: editionId },
orderBy: { createdAt: 'desc' },
take: 5,
select: {
id: true,
name: true,
status: true,
votingStartAt: true,
votingEndAt: true,
submissionEndDate: true,
_count: {
select: {
projects: true,
assignments: true,
},
},
assignments: {
select: {
evaluation: { select: { status: true } },
},
},
},
}),
ctx.prisma.project.findMany({
where: { programId: editionId },
orderBy: { createdAt: 'desc' },
take: 8,
select: {
id: true,
title: true,
teamName: true,
country: true,
competitionCategory: true,
oceanIssue: true,
logoKey: true,
createdAt: true,
submittedAt: true,
status: true,
round: { select: { name: true } },
},
}),
ctx.prisma.project.groupBy({
by: ['competitionCategory'],
where: { programId: editionId },
_count: true,
}),
ctx.prisma.project.groupBy({
by: ['oceanIssue'],
where: { programId: editionId },
_count: true,
}),
ctx.prisma.auditLog.findMany({
where: {
timestamp: { gte: sevenDaysAgo },
},
orderBy: { timestamp: 'desc' },
take: 8,
select: {
id: true,
action: true,
entityType: true,
timestamp: true,
user: { select: { name: true } },
},
}),
ctx.prisma.conflictOfInterest.count({
where: {
hasConflict: true,
reviewedAt: null,
assignment: { round: { programId: editionId } },
},
}),
ctx.prisma.round.count({
where: { programId: editionId, status: 'DRAFT' },
}),
ctx.prisma.project.count({
where: {
programId: editionId,
round: { status: 'ACTIVE' },
assignments: { none: {} },
},
}),
])
return {
edition,
activeRoundCount,
totalRoundCount,
projectCount,
newProjectsThisWeek,
totalJurors,
activeJurors,
evaluationStats,
totalAssignments,
recentRounds,
latestProjects,
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
pendingCOIs,
draftRounds,
unassignedProjects,
}
}),
})

View File

@ -681,7 +681,7 @@ export const mentorRouter = router({
type: 'MENTOR_MESSAGE', type: 'MENTOR_MESSAGE',
title: 'New Message from Mentor', title: 'New Message from Mentor',
message: `${ctx.user.name || 'Your mentor'} sent you a message`, message: `${ctx.user.name || 'Your mentor'} sent you a message`,
linkUrl: `/my-submission/${input.projectId}`, linkUrl: `/applicant/mentor`,
linkLabel: 'View Message', linkLabel: 'View Message',
priority: 'normal', priority: 'normal',
metadata: { metadata: {

View File

@ -1164,4 +1164,121 @@ export const projectRouter = router({
return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage) } return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage) }
}), }),
/**
* Get full project detail with assignments and evaluation stats in one call.
* Reduces client-side waterfall by combining project.get + assignment.listByProject + evaluation.getProjectStats.
*/
getFullDetail: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const [projectRaw, projectTags, assignments, submittedEvaluations] = await Promise.all([
ctx.prisma.project.findUniqueOrThrow({
where: { id: input.id },
include: {
files: true,
round: true,
teamMembers: {
include: {
user: {
select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true },
},
},
orderBy: { joinedAt: 'asc' },
},
mentorAssignment: {
include: {
mentor: {
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
},
},
},
},
}),
ctx.prisma.projectTag.findMany({
where: { projectId: input.id },
include: { tag: { select: { id: true, name: true, category: true, color: true } } },
orderBy: { confidence: 'desc' },
}).catch(() => [] as { id: string; projectId: string; tagId: string; confidence: number; tag: { id: string; name: string; category: string | null; color: string | null } }[]),
ctx.prisma.assignment.findMany({
where: { projectId: input.id },
include: {
user: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true } },
evaluation: { select: { status: true, submittedAt: true, globalScore: true, binaryDecision: true } },
},
orderBy: { createdAt: 'desc' },
}),
ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: { projectId: input.id },
},
}),
])
// Compute evaluation stats
let stats = null
if (submittedEvaluations.length > 0) {
const globalScores = submittedEvaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
const yesVotes = submittedEvaluations.filter((e) => e.binaryDecision === true).length
stats = {
totalEvaluations: submittedEvaluations.length,
averageGlobalScore: globalScores.length > 0
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
: null,
minScore: globalScores.length > 0 ? Math.min(...globalScores) : null,
maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null,
yesVotes,
noVotes: submittedEvaluations.length - yesVotes,
yesPercentage: (yesVotes / submittedEvaluations.length) * 100,
}
}
// Attach avatar URLs in parallel
const [teamMembersWithAvatars, assignmentsWithAvatars, mentorWithAvatar] = await Promise.all([
Promise.all(
projectRaw.teamMembers.map(async (member) => ({
...member,
user: {
...member.user,
avatarUrl: await getUserAvatarUrl(member.user.profileImageKey, member.user.profileImageProvider),
},
}))
),
Promise.all(
assignments.map(async (a) => ({
...a,
user: {
...a.user,
avatarUrl: await getUserAvatarUrl(a.user.profileImageKey, a.user.profileImageProvider),
},
}))
),
projectRaw.mentorAssignment
? (async () => ({
...projectRaw.mentorAssignment!,
mentor: {
...projectRaw.mentorAssignment!.mentor,
avatarUrl: await getUserAvatarUrl(
projectRaw.mentorAssignment!.mentor.profileImageKey,
projectRaw.mentorAssignment!.mentor.profileImageProvider
),
},
}))()
: Promise.resolve(null),
])
return {
project: {
...projectRaw,
projectTags,
teamMembers: teamMembersWithAvatars,
mentorAssignment: mentorWithAvatar,
},
assignments: assignmentsWithAvatars,
stats,
}
}),
}) })