From 98f4a957ccaae2b6e6c8a11dee4288a5f7337228 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 11 Feb 2026 11:04:26 +0100 Subject: [PATCH] Performance optimization, applicant portal, and missing DB migration 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 --- next.config.ts | 3 + .../migration.sql | 129 +++ src/app/(admin)/admin/dashboard-content.tsx | 792 +++++++++++++ src/app/(admin)/admin/page.tsx | 1014 +---------------- src/app/(admin)/admin/projects/[id]/page.tsx | 18 +- src/app/(admin)/error.tsx | 51 +- .../(applicant)/applicant/documents/page.tsx | 234 ++++ src/app/(applicant)/applicant/mentor/page.tsx | 138 +++ src/app/(applicant)/applicant/page.tsx | 360 ++++++ src/app/(applicant)/applicant/team/page.tsx | 437 +++++++ src/app/(applicant)/layout.tsx | 33 + src/app/(jury)/error.tsx | 43 +- src/app/(jury)/jury/page.tsx | 100 +- src/app/(mentor)/error.tsx | 43 +- src/app/(observer)/error.tsx | 43 +- src/app/(public)/error.tsx | 43 +- src/app/(settings)/layout.tsx | 1 + src/app/error.tsx | 37 +- src/app/page.tsx | 2 +- src/app/providers.tsx | 2 +- src/components/layouts/admin-sidebar.tsx | 7 +- src/components/layouts/applicant-nav.tsx | 42 + src/components/layouts/role-nav.tsx | 8 +- src/components/shared/notification-bell.tsx | 11 +- src/lib/auth-redirect.ts | 1 + src/lib/chunk-error-recovery.ts | 34 + src/lib/prisma.ts | 10 + src/server/routers/_app.ts | 2 + src/server/routers/applicant.ts | 182 +++ src/server/routers/dashboard.ts | 184 +++ src/server/routers/mentor.ts | 2 +- src/server/routers/project.ts | 117 ++ 32 files changed, 3002 insertions(+), 1121 deletions(-) create mode 100644 prisma/migrations/20260211100000_add_missing_schema_elements/migration.sql create mode 100644 src/app/(admin)/admin/dashboard-content.tsx create mode 100644 src/app/(applicant)/applicant/documents/page.tsx create mode 100644 src/app/(applicant)/applicant/mentor/page.tsx create mode 100644 src/app/(applicant)/applicant/page.tsx create mode 100644 src/app/(applicant)/applicant/team/page.tsx create mode 100644 src/app/(applicant)/layout.tsx create mode 100644 src/components/layouts/applicant-nav.tsx create mode 100644 src/lib/chunk-error-recovery.ts create mode 100644 src/server/routers/dashboard.ts diff --git a/next.config.ts b/next.config.ts index becc049..4a4414e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,9 @@ const nextConfig: NextConfig = { output: 'standalone', typedRoutes: true, serverExternalPackages: ['@prisma/client', 'minio'], + experimental: { + optimizePackageImports: ['lucide-react'], + }, images: { remotePatterns: [ { diff --git a/prisma/migrations/20260211100000_add_missing_schema_elements/migration.sql b/prisma/migrations/20260211100000_add_missing_schema_elements/migration.sql new file mode 100644 index 0000000..6c13c7b --- /dev/null +++ b/prisma/migrations/20260211100000_add_missing_schema_elements/migration.sql @@ -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 +-- ============================================================================= diff --git a/src/app/(admin)/admin/dashboard-content.tsx b/src/app/(admin)/admin/dashboard-content.tsx new file mode 100644 index 0000000..1fd9955 --- /dev/null +++ b/src/app/(admin)/admin/dashboard-content.tsx @@ -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 = { + 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 + case 'UPDATE': return + case 'DELETE': return + case 'LOGIN': return + case 'EXPORT': return + case 'SUBMIT': return + case 'ASSIGN': return + case 'INVITE': return + default: return + } +} + +export function DashboardContent({ editionId, sessionName }: DashboardContentProps) { + const { data, isLoading } = trpc.dashboard.getStats.useQuery( + { editionId }, + { enabled: !!editionId } + ) + + if (isLoading) { + return + } + + if (!data) { + return ( + + + +

Edition not found

+

+ The selected edition could not be found +

+
+
+ ) + } + + 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 */} +
+

Dashboard

+

+ Welcome back, {sessionName} — {edition.name} {edition.year} +

+
+ + {/* Stats Grid */} +
+ + + + Rounds + + + +
{totalRoundCount}
+

+ {activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''} +

+
+
+
+ + + + + Projects + + + +
{projectCount}
+

+ {newProjectsThisWeek > 0 + ? `${newProjectsThisWeek} new this week` + : 'In this edition'} +

+
+
+
+ + + + + Jury Members + + + +
{totalJurors}
+

+ {activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`} +

+
+
+
+ + + + + Evaluations + + + +
+ {submittedCount} + {totalAssignments > 0 && ( + + {' '}/ {totalAssignments} + + )} +
+
+ +

+ {completionRate.toFixed(0)}% completion rate +

+
+
+
+
+
+ + {/* Quick Actions */} +
+ + + +
+ + {/* Two-Column Content */} +
+ {/* Left Column */} +
+ {/* Rounds Card (enhanced) */} + + +
+
+ Rounds + + Voting rounds in {edition.name} + +
+ + View all + +
+
+ + {roundsWithEvalStats.length === 0 ? ( +
+ +

+ No rounds created yet +

+ + Create your first round + +
+ ) : ( +
+ {roundsWithEvalStats.map((round) => ( + +
+
+
+
+

{round.name}

+ +
+

+ {round._count.projects} projects · {round._count.assignments} assignments + {round.totalEvals > 0 && ( + <> · {round.evalPercent}% evaluated + )} +

+ {round.votingStartAt && round.votingEndAt && ( +

+ Voting: {formatDateOnly(round.votingStartAt)} – {formatDateOnly(round.votingEndAt)} +

+ )} +
+
+ {round.totalEvals > 0 && ( + + )} +
+ + ))} +
+ )} +
+
+ + {/* Latest Projects Card */} + + +
+
+ Latest Projects + Recently submitted projects +
+ + View all + +
+
+ + {latestProjects.length === 0 ? ( +
+ +

+ No projects submitted yet +

+
+ ) : ( +
+ {latestProjects.map((project) => ( + +
+ +
+
+

+ {truncate(project.title, 45)} +

+ +
+

+ {[ + project.teamName, + project.country ? getCountryName(project.country) : null, + formatDateOnly(project.submittedAt || project.createdAt), + ] + .filter(Boolean) + .join(' \u00b7 ')} +

+ {(project.competitionCategory || project.oceanIssue) && ( +

+ {[ + project.competitionCategory + ? formatEnumLabel(project.competitionCategory) + : null, + project.oceanIssue + ? formatEnumLabel(project.oceanIssue) + : null, + ] + .filter(Boolean) + .join(' \u00b7 ')} +

+ )} +
+
+ + ))} +
+ )} +
+
+
+ + {/* Right Column */} +
+ {/* Pending Actions Card */} + + + + + Pending Actions + + + +
+ {pendingCOIs > 0 && ( + +
+ + COI declarations to review +
+ {pendingCOIs} + + )} + {unassignedProjects > 0 && ( + +
+ + Projects without assignments +
+ {unassignedProjects} + + )} + {draftRounds > 0 && ( + +
+ + Draft rounds to activate +
+ {draftRounds} + + )} + {pendingCOIs === 0 && unassignedProjects === 0 && draftRounds === 0 && ( +
+ +

All caught up!

+
+ )} +
+
+
+ + {/* Evaluation Progress Card */} + + + + + Evaluation Progress + + + + {roundsWithEvalStats.filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0).length === 0 ? ( +
+ +

+ No evaluations in progress +

+
+ ) : ( +
+ {roundsWithEvalStats + .filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0) + .map((round) => ( +
+
+

{round.name}

+ + {round.evalPercent}% + +
+ +

+ {round.submittedEvals} of {round.totalEvals} evaluations submitted +

+
+ ))} +
+ )} +
+
+ + {/* Category Breakdown Card */} + + + + + Project Categories + + + + {categories.length === 0 && issues.length === 0 ? ( +
+ +

+ No category data available +

+
+ ) : ( +
+ {categories.length > 0 && ( +
+

+ By Type +

+ {categories.map((cat) => ( +
+
+ {cat.label} + {cat.count} +
+
+
+
+
+ ))} +
+ )} + {issues.length > 0 && ( +
+

+ Top Issues +

+ {issues.map((issue) => ( +
+
+ {issue.label} + {issue.count} +
+
+
+
+
+ ))} +
+ )} +
+ )} + + + + {/* Recent Activity Card */} + + + + + Recent Activity + + + + {recentActivity.length === 0 ? ( +
+ +

+ No recent activity +

+
+ ) : ( +
+ {recentActivity.map((log) => ( +
+
+ {getActionIcon(log.action)} +
+
+

+ {log.user?.name || 'System'} + {' '}{formatAction(log.action, log.entityType)} +

+

+ {formatRelativeTime(log.timestamp)} +

+
+
+ ))} +
+ )} +
+
+ + {/* Upcoming Deadlines Card */} + + + + + Upcoming Deadlines + + + + {upcomingDeadlines.length === 0 ? ( +
+ +

+ No upcoming deadlines +

+
+ ) : ( +
+ {upcomingDeadlines.map((deadline, i) => { + const days = daysUntil(deadline.date) + const isUrgent = days <= 7 + return ( +
+
+ +
+
+

+ {deadline.label} — {deadline.roundName} +

+

+ {formatDateOnly(deadline.date)} · in {days} day{days !== 1 ? 's' : ''} +

+
+
+ ) + })} +
+ )} +
+
+
+
+ + {/* Geographic Distribution (full width, at the bottom) */} + + + ) +} + +function DashboardSkeleton() { + return ( + <> + {/* Header skeleton */} +
+ + +
+ + {/* Stats grid skeleton */} +
+ {[...Array(4)].map((_, i) => ( + + + + + + + + + + ))} +
+ + {/* Two-column content skeleton */} +
+
+ + + + + + +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+
+
+ + + + + + +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+
+
+
+
+ + + +
+ {[...Array(2)].map((_, i) => ( + + ))} +
+
+
+ + + +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+
+
+ + + +
+ {[...Array(2)].map((_, i) => ( + + ))} +
+
+
+
+
+ + {/* Map skeleton */} + + + ) +} diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx index 37ae121..289703f 100644 --- a/src/app/(admin)/admin/page.tsx +++ b/src/app/(admin)/admin/page.tsx @@ -1,1002 +1,72 @@ import type { Metadata } from 'next' -import { Suspense } from 'react' import { auth } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import Link from 'next/link' - -export const metadata: Metadata = { title: 'Admin Dashboard' } -export const dynamic = 'force-dynamic' 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' +import { CircleDot } from 'lucide-react' +import { DashboardContent } from './dashboard-content' -type DashboardStatsProps = { - editionId: string | null - sessionName: string -} - -const statusColors: Record = { - SUBMITTED: 'secondary', - ELIGIBLE: 'default', - ASSIGNED: 'default', - UNDER_REVIEW: 'default', - SHORTLISTED: 'success', - SEMIFINALIST: 'success', - FINALIST: 'success', - WINNER: 'success', - REJECTED: 'destructive', - WITHDRAWN: 'secondary', -} - -async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) { - if (!editionId) { - return ( - - - -

No edition selected

-

- Select an edition from the sidebar to view dashboard -

-
-
- ) - } - - try { - const edition = await prisma.program.findUnique({ - where: { id: editionId }, - select: { name: true, year: true }, - }) - - if (!edition) { - return ( - - - -

Edition not found

-

- The selected edition could not be found -

-
-
- ) - } - - 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([ - prisma.round.count({ - where: { programId: editionId, status: 'ACTIVE' }, - }), - prisma.round.count({ - where: { programId: editionId }, - }), - prisma.project.count({ - where: { programId: editionId }, - }), - prisma.project.count({ - where: { - programId: editionId, - createdAt: { gte: sevenDaysAgo }, - }, - }), - prisma.user.count({ - where: { - role: 'JURY_MEMBER', - status: { in: ['ACTIVE', 'INVITED', 'NONE'] }, - assignments: { some: { round: { programId: editionId } } }, - }, - }), - prisma.user.count({ - where: { - role: 'JURY_MEMBER', - status: 'ACTIVE', - assignments: { some: { round: { programId: editionId } } }, - }, - }), - prisma.evaluation.groupBy({ - by: ['status'], - where: { assignment: { round: { programId: editionId } } }, - _count: true, - }), - prisma.assignment.count({ - where: { round: { programId: editionId } }, - }), - 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 } }, - }, - }, - }, - }), - 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 } }, - }, - }), - prisma.project.groupBy({ - by: ['competitionCategory'], - where: { programId: editionId }, - _count: true, - }), - prisma.project.groupBy({ - by: ['oceanIssue'], - where: { programId: editionId }, - _count: true, - }), - // Recent activity feed (scoped to last 7 days for performance) - 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 } }, - }, - }), - // Pending COI declarations (hasConflict declared but not yet reviewed) - prisma.conflictOfInterest.count({ - where: { - hasConflict: true, - reviewedAt: null, - assignment: { round: { programId: editionId } }, - }, - }), - // Draft rounds needing activation - prisma.round.count({ - where: { programId: editionId, status: 'DRAFT' }, - }), - // Projects without assignments in active rounds - prisma.project.count({ - where: { - programId: editionId, - round: { status: 'ACTIVE' }, - assignments: { none: {} }, - }, - }), - ]) - - 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) - - // Helper: human-readable action descriptions for audit log - function formatAction(action: string, entityType: string | null): string { - const entity = entityType?.toLowerCase() || 'record' - const actionMap: Record = { - 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}` - } - - // Helper: pick an icon for an audit action - function getActionIcon(action: string) { - switch (action) { - case 'CREATE': return - case 'UPDATE': return - case 'DELETE': return - case 'LOGIN': return - case 'EXPORT': return - case 'SUBMIT': return - case 'ASSIGN': return - case 'INVITE': return - default: return - } - } - - return ( - <> - {/* Header */} -
-

Dashboard

-

- Welcome back, {sessionName} — {edition.name} {edition.year} -

-
- - {/* Stats Grid */} -
- - - - Rounds - - - -
{totalRoundCount}
-

- {activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''} -

-
-
-
- - - - - Projects - - - -
{projectCount}
-

- {newProjectsThisWeek > 0 - ? `${newProjectsThisWeek} new this week` - : 'In this edition'} -

-
-
-
- - - - - Jury Members - - - -
{totalJurors}
-

- {activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`} -

-
-
-
- - - - - Evaluations - - - -
- {submittedCount} - {totalAssignments > 0 && ( - - {' '}/ {totalAssignments} - - )} -
-
- -

- {completionRate.toFixed(0)}% completion rate -

-
-
-
-
-
- - {/* Quick Actions */} -
- - - -
- - {/* Two-Column Content */} -
- {/* Left Column */} -
- {/* Rounds Card (enhanced) */} - - -
-
- Rounds - - Voting rounds in {edition.name} - -
- - View all - -
-
- - {roundsWithEvalStats.length === 0 ? ( -
- -

- No rounds created yet -

- - Create your first round - -
- ) : ( -
- {roundsWithEvalStats.map((round) => ( - -
-
-
-
-

{round.name}

- -
-

- {round._count.projects} projects · {round._count.assignments} assignments - {round.totalEvals > 0 && ( - <> · {round.evalPercent}% evaluated - )} -

- {round.votingStartAt && round.votingEndAt && ( -

- Voting: {formatDateOnly(round.votingStartAt)} – {formatDateOnly(round.votingEndAt)} -

- )} -
-
- {round.totalEvals > 0 && ( - - )} -
- - ))} -
- )} -
-
- - {/* Latest Projects Card */} - - -
-
- Latest Projects - Recently submitted projects -
- - View all - -
-
- - {latestProjects.length === 0 ? ( -
- -

- No projects submitted yet -

-
- ) : ( -
- {latestProjects.map((project) => ( - -
- -
-
-

- {truncate(project.title, 45)} -

- -
-

- {[ - project.teamName, - project.country ? getCountryName(project.country) : null, - formatDateOnly(project.submittedAt || project.createdAt), - ] - .filter(Boolean) - .join(' \u00b7 ')} -

- {(project.competitionCategory || project.oceanIssue) && ( -

- {[ - project.competitionCategory - ? formatEnumLabel(project.competitionCategory) - : null, - project.oceanIssue - ? formatEnumLabel(project.oceanIssue) - : null, - ] - .filter(Boolean) - .join(' \u00b7 ')} -

- )} -
-
- - ))} -
- )} -
-
-
- - {/* Right Column */} -
- {/* Pending Actions Card */} - - - - - Pending Actions - - - -
- {pendingCOIs > 0 && ( - -
- - COI declarations to review -
- {pendingCOIs} - - )} - {unassignedProjects > 0 && ( - -
- - Projects without assignments -
- {unassignedProjects} - - )} - {draftRounds > 0 && ( - -
- - Draft rounds to activate -
- {draftRounds} - - )} - {pendingCOIs === 0 && unassignedProjects === 0 && draftRounds === 0 && ( -
- -

All caught up!

-
- )} -
-
-
- - {/* Evaluation Progress Card */} - - - - - Evaluation Progress - - - - {roundsWithEvalStats.filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0).length === 0 ? ( -
- -

- No evaluations in progress -

-
- ) : ( -
- {roundsWithEvalStats - .filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0) - .map((round) => ( -
-
-

{round.name}

- - {round.evalPercent}% - -
- -

- {round.submittedEvals} of {round.totalEvals} evaluations submitted -

-
- ))} -
- )} -
-
- - {/* Category Breakdown Card */} - - - - - Project Categories - - - - {categories.length === 0 && issues.length === 0 ? ( -
- -

- No category data available -

-
- ) : ( -
- {categories.length > 0 && ( -
-

- By Type -

- {categories.map((cat) => ( -
-
- {cat.label} - {cat.count} -
-
-
-
-
- ))} -
- )} - {issues.length > 0 && ( -
-

- Top Issues -

- {issues.map((issue) => ( -
-
- {issue.label} - {issue.count} -
-
-
-
-
- ))} -
- )} -
- )} - - - - {/* Recent Activity Card */} - - - - - Recent Activity - - - - {recentActivity.length === 0 ? ( -
- -

- No recent activity -

-
- ) : ( -
- {recentActivity.map((log) => ( -
-
- {getActionIcon(log.action)} -
-
-

- {log.user?.name || 'System'} - {' '}{formatAction(log.action, log.entityType)} -

-

- {formatRelativeTime(log.timestamp)} -

-
-
- ))} -
- )} -
-
- - {/* Upcoming Deadlines Card */} - - - - - Upcoming Deadlines - - - - {upcomingDeadlines.length === 0 ? ( -
- -

- No upcoming deadlines -

-
- ) : ( -
- {upcomingDeadlines.map((deadline, i) => { - const days = daysUntil(deadline.date) - const isUrgent = days <= 7 - return ( -
-
- -
-
-

- {deadline.label} — {deadline.roundName} -

-

- {formatDateOnly(deadline.date)} · in {days} day{days !== 1 ? 's' : ''} -

-
-
- ) - })} -
- )} -
-
-
-
- - {/* Geographic Distribution (full width, at the bottom) */} - - - ) - } catch (err) { - console.error('Dashboard data load failed:', err) - return ( - - - -

Dashboard temporarily unavailable

-

- Could not load dashboard data. Please refresh the page. -

-
-
- ) - } -} - -function DashboardSkeleton() { - return ( - <> - {/* Header skeleton */} -
- - -
- - {/* Stats grid skeleton */} -
- {[...Array(4)].map((_, i) => ( - - - - - - - - - - ))} -
- - {/* Two-column content skeleton */} -
-
- - - - - - -
- {[...Array(3)].map((_, i) => ( - - ))} -
-
-
- - - - - - -
- {[...Array(5)].map((_, i) => ( - - ))} -
-
-
-
-
- - - -
- {[...Array(2)].map((_, i) => ( - - ))} -
-
-
- - - -
- {[...Array(4)].map((_, i) => ( - - ))} -
-
-
- - - -
- {[...Array(2)].map((_, i) => ( - - ))} -
-
-
-
-
- - {/* Map skeleton */} - - - ) -} +export const metadata: Metadata = { title: 'Admin Dashboard' } +export const dynamic = 'force-dynamic' type PageProps = { searchParams: Promise<{ edition?: string }> } export default async function AdminDashboardPage({ searchParams }: PageProps) { - const [session, params] = await Promise.all([ - auth(), - searchParams, - ]) + let editionId: string | null = null + let sessionName = 'Admin' - let editionId = params.edition || null + try { + const [session, params] = await Promise.all([ + auth(), + searchParams, + ]) - if (!editionId) { - const defaultEdition = await prisma.program.findFirst({ - where: { status: 'ACTIVE' }, - orderBy: { year: 'desc' }, - select: { id: true }, - }) - editionId = defaultEdition?.id || null + editionId = params.edition || null + sessionName = session?.user?.name || 'Admin' if (!editionId) { - const anyEdition = await prisma.program.findFirst({ + const defaultEdition = await prisma.program.findFirst({ + where: { status: 'ACTIVE' }, orderBy: { year: 'desc' }, select: { id: true }, }) - editionId = anyEdition?.id || null + editionId = defaultEdition?.id || null + + if (!editionId) { + const anyEdition = await prisma.program.findFirst({ + orderBy: { year: 'desc' }, + select: { id: true }, + }) + editionId = anyEdition?.id || null + } } + } catch (err) { + console.error('[AdminDashboard] Page init failed:', err) } - const sessionName = session?.user?.name || 'Admin' + if (!editionId) { + return ( +
+ + + +

No edition selected

+

+ Select an edition from the sidebar to view dashboard +

+
+
+
+ ) + } return (
- }> - - +
) } diff --git a/src/app/(admin)/admin/projects/[id]/page.tsx b/src/app/(admin)/admin/projects/[id]/page.tsx index 41e0893..2b3ddd4 100644 --- a/src/app/(admin)/admin/projects/[id]/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/page.tsx @@ -73,11 +73,15 @@ const evalStatusColors: Record { console.error('Admin section error:', error) + + if (isChunkLoadError(error)) { + attemptChunkErrorRecovery('admin') + } }, [error]) + const isChunk = isChunkLoadError(error) + return (
@@ -28,26 +35,36 @@ export default function AdminError({

- An error occurred while loading this admin page. Please try again or - return to the dashboard. + {isChunk + ? '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.'}

-
- - -
- {error.digest && ( -

- Error ID: {error.digest} + {!isChunk && (error.message || error.digest) && ( +

+ {error.message || `Error ID: ${error.digest}`}

)} +
+ {isChunk ? ( + + ) : ( + <> + + + + )} +
diff --git a/src/app/(applicant)/applicant/documents/page.tsx b/src/app/(applicant)/applicant/documents/page.tsx new file mode 100644 index 0000000..dddfada --- /dev/null +++ b/src/app/(applicant)/applicant/documents/page.tsx @@ -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 = { + EXEC_SUMMARY: FileText, + BUSINESS_PLAN: FileText, + PRESENTATION: FileText, + VIDEO_PITCH: Video, + VIDEO: Video, + OTHER: File, + SUPPORTING_DOC: File, +} + +const fileTypeLabels: Record = { + 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 ( +
+
+ + +
+ + +
+ ) + } + + if (!data?.project) { + return ( +
+
+

Documents

+
+ + + +

No Project

+

+ Submit a project first to upload documents. +

+
+
+
+ ) + } + + const { project, openRounds } = data + const isDraft = !project.submittedAt + + return ( +
+ {/* Header */} +
+

+ + Documents +

+

+ Upload and manage documents for your project: {project.title} +

+
+ + {/* Per-round upload sections */} + {openRounds.length > 0 && ( +
+ {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 ( + + +
+
+ {round.name} + + Upload documents for this round + +
+
+ {isLate && ( + + + Late submission + + )} + {hasDeadline && !deadlinePassed && ( + + + Due {new Date(round.submissionDeadline!).toLocaleDateString()} + + )} +
+
+
+ + + +
+ ) + })} +
+ )} + + {/* Original round upload (if not already in openRounds) */} + {project.roundId && !openRounds.some((r) => r.id === project.roundId) && ( + + + + {project.round?.name || 'Submission Documents'} + + + Documents uploaded with your original application + + + + + + + )} + + {/* Uploaded files list */} + + + All Uploaded Documents + + All files associated with your project + + + + {project.files.length === 0 ? ( +

+ No documents uploaded yet +

+ ) : ( +
+ {project.files.map((file) => { + const Icon = fileTypeIcons[file.fileType] || File + const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null } + + return ( +
+
+ +
+
+

{file.fileName}

+ {fileRecord.isLate && ( + + + Late + + )} +
+

+ {fileTypeLabels[file.fileType] || file.fileType} + {' - '} + {new Date(file.createdAt).toLocaleDateString()} +

+
+
+
+ ) + })} +
+ )} +
+
+ + {/* No open rounds message */} + {openRounds.length === 0 && !project.roundId && ( + + + +

+ No rounds are currently open for document submissions. +

+
+
+ )} +
+ ) +} diff --git a/src/app/(applicant)/applicant/mentor/page.tsx b/src/app/(applicant)/applicant/mentor/page.tsx new file mode 100644 index 0000000..d48eeaf --- /dev/null +++ b/src/app/(applicant)/applicant/mentor/page.tsx @@ -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 ( +
+
+ + +
+ +
+ ) + } + + if (!projectId) { + return ( +
+
+

Mentor

+
+ + + +

No Project

+

+ Submit a project first to communicate with your mentor. +

+
+
+
+ ) + } + + const mentor = dashboardData?.project?.mentorAssignment?.mentor + + return ( +
+ {/* Header */} +
+

+ + Mentor Communication +

+

+ Chat with your assigned mentor +

+
+ + {/* Mentor info */} + {mentor ? ( + + +
+ +
+

{mentor.name || 'Mentor'}

+

{mentor.email}

+
+
+
+
+ ) : ( + + + +

+ No mentor has been assigned to your project yet. + You'll be notified when a mentor is assigned. +

+
+
+ )} + + {/* Chat */} + {mentor && ( + + + Messages + + Your conversation history with {mentor.name || 'your mentor'} + + + + { + await sendMessage.mutateAsync({ projectId: projectId!, message }) + }} + isLoading={messagesLoading} + isSending={sendMessage.isPending} + /> + + + )} +
+ ) +} diff --git a/src/app/(applicant)/applicant/page.tsx b/src/app/(applicant)/applicant/page.tsx new file mode 100644 index 0000000..1774b21 --- /dev/null +++ b/src/app/(applicant)/applicant/page.tsx @@ -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 = { + 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 ( +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ ) + } + + // No project yet + if (!data?.project) { + return ( +
+
+

My Project

+

+ Your applicant dashboard +

+
+ + + +

No Project Yet

+

+ You haven't submitted a project yet. Check for open application rounds + on the MOPC website. +

+
+
+
+ ) + } + + const { project, timeline, currentStatus, openRounds } = data + const isDraft = !project.submittedAt + const programYear = project.round?.program?.year + const roundName = project.round?.name + + return ( +
+ {/* Header */} +
+
+
+

{project.title}

+ {currentStatus && ( + + {currentStatus.replace('_', ' ')} + + )} +
+

+ {programYear ? `${programYear} Edition` : ''}{roundName ? ` - ${roundName}` : ''} +

+
+
+ + {/* Draft warning */} + {isDraft && ( + + + Draft Submission + + This submission has not been submitted yet. You can continue editing and submit when ready. + + + )} + +
+ {/* Main content */} +
+ {/* Project details */} + + + Project Details + + + {project.teamName && ( +
+

Team/Organization

+

{project.teamName}

+
+ )} + {project.description && ( +
+

Description

+

{project.description}

+
+ )} + {project.tags && project.tags.length > 0 && ( +
+

Tags

+
+ {project.tags.map((tag) => ( + + {tag} + + ))} +
+
+ )} + + {/* Metadata */} + {project.metadataJson && Object.keys(project.metadataJson as Record).length > 0 && ( +
+

Additional Information

+
+ {Object.entries(project.metadataJson as Record).map(([key, value]) => ( +
+
+ {key.replace(/_/g, ' ')} +
+
{String(value)}
+
+ ))} +
+
+ )} + + {/* Meta info row */} +
+
+ + Created {new Date(project.createdAt).toLocaleDateString()} +
+ {project.submittedAt ? ( +
+ + Submitted {new Date(project.submittedAt).toLocaleDateString()} +
+ ) : ( +
+ + Draft +
+ )} +
+ + {project.files.length} file(s) +
+
+
+
+ + {/* Quick actions */} +
+ + + +
+ +
+
+

Documents

+

+ {openRounds.length > 0 ? `${openRounds.length} round(s) open` : 'View uploads'} +

+
+ + +
+
+ + + + +
+ +
+
+

Team

+

+ {project.teamMembers.length} member(s) +

+
+ + +
+
+ + + + +
+ +
+
+

Mentor

+

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

+
+ + +
+
+
+
+ + {/* Sidebar */} +
+ {/* Status timeline */} + + + Status Timeline + + + + + + + {/* Team overview */} + + +
+ + + Team + + +
+
+ + {project.teamMembers.length > 0 ? ( + project.teamMembers.slice(0, 5).map((member) => ( +
+
+ {member.role === 'LEAD' ? ( + + ) : ( + + {member.user.name?.charAt(0).toUpperCase() || '?'} + + )} +
+
+

+ {member.user.name || member.user.email} +

+

+ {member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'} +

+
+
+ )) + ) : ( +

+ No team members yet +

+ )} + {project.teamMembers.length > 5 && ( +

+ +{project.teamMembers.length - 5} more +

+ )} +
+
+ + {/* Key dates */} + + + Key Dates + + +
+ Created + {new Date(project.createdAt).toLocaleDateString()} +
+ {project.submittedAt && ( +
+ Submitted + {new Date(project.submittedAt).toLocaleDateString()} +
+ )} +
+ Last Updated + {new Date(project.updatedAt).toLocaleDateString()} +
+ {project.round?.submissionDeadline && ( +
+ Deadline + {new Date(project.round.submissionDeadline).toLocaleDateString()} +
+ )} +
+
+
+
+
+ ) +} diff --git a/src/app/(applicant)/applicant/team/page.tsx b/src/app/(applicant)/applicant/team/page.tsx new file mode 100644 index 0000000..5fd83e7 --- /dev/null +++ b/src/app/(applicant)/applicant/team/page.tsx @@ -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 + +const roleLabels: Record = { + LEAD: 'Team Lead', + MEMBER: 'Team Member', + ADVISOR: 'Advisor', +} + +const statusLabels: Record }> = { + 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({ + 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 ( +
+
+ + +
+ + + {[1, 2, 3].map((i) => ( +
+
+ +
+ + +
+
+ +
+ ))} +
+
+
+ ) + } + + if (!projectId) { + return ( +
+
+

Team

+
+ + + +

No Project

+

+ Submit a project first to manage your team. +

+
+
+
+ ) + } + + // 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 ( +
+ {/* Header */} +
+
+

+ + Team Members +

+

+ Manage your project team +

+
+ + {isTeamLead && ( + + + + + + + Invite Team Member + + Send an invitation to join your project team. They will receive an email + with instructions to create their account. + + +
+
+ + + {form.formState.errors.name && ( +

+ {form.formState.errors.name.message} +

+ )} +
+
+ + + {form.formState.errors.email && ( +

+ {form.formState.errors.email.message} +

+ )} +
+
+
+ + +
+
+ + +
+
+ + + + +
+
+
+ )} +
+ + {/* Team Members List */} + + + Team ({teamData?.teamMembers.length || 0} members) + + Everyone on this list can view and collaborate on this project. + + + + {teamData?.teamMembers.map((member) => { + const StatusIcon = statusLabels[member.user.status]?.icon || AlertCircle + + return ( +
+
+
+ {member.role === 'LEAD' ? ( + + ) : ( + + {member.user.name?.charAt(0).toUpperCase() || '?'} + + )} +
+
+
+ {member.user.name} + + {roleLabels[member.role] || member.role} + + {member.title && ( + + ({member.title}) + + )} +
+
+ + {member.user.email} + + + {statusLabels[member.user.status]?.label || member.user.status} + +
+
+
+ + {isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && ( + + + + + + + Remove Team Member + + Are you sure you want to remove {member.user.name} from the team? + They will no longer have access to this project. + + + + Cancel + removeMutation.mutate({ projectId, userId: member.userId })} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Remove + + + + + )} +
+ ) + })} + + {(!teamData?.teamMembers || teamData.teamMembers.length === 0) && ( +
+ +

No team members yet.

+ {isTeamLead && ( + + )} +
+ )} +
+
+ + {/* Team Documents */} + {teamData?.roundId && ( + + + Team Documents + + Upload required documents for your project. Any team member can upload files. + + + + + + + )} + + {/* Info Card */} + + +
+ +
+

About Team Access

+

+ 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. +

+
+
+
+
+
+ ) +} diff --git a/src/app/(applicant)/layout.tsx b/src/app/(applicant)/layout.tsx new file mode 100644 index 0000000..095eda7 --- /dev/null +++ b/src/app/(applicant)/layout.tsx @@ -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 ( +
+ +
{children}
+
+ ) +} diff --git a/src/app/(jury)/error.tsx b/src/app/(jury)/error.tsx index 5e09c88..b55cf7a 100644 --- a/src/app/(jury)/error.tsx +++ b/src/app/(jury)/error.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { AlertTriangle, RefreshCw, ClipboardList } from 'lucide-react' +import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery' export default function JuryError({ error, @@ -15,8 +16,14 @@ export default function JuryError({ }) { useEffect(() => { console.error('Jury section error:', error) + + if (isChunkLoadError(error)) { + attemptChunkErrorRecovery('jury') + } }, [error]) + const isChunk = isChunkLoadError(error) + return (
@@ -28,22 +35,32 @@ export default function JuryError({

- An error occurred while loading this page. Please try again or - return to your assignments. + {isChunk + ? '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.'}

- - + {isChunk ? ( + + ) : ( + <> + + + + )}
- {error.digest && ( + {!isChunk && error.digest && (

Error ID: {error.digest}

diff --git a/src/app/(jury)/jury/page.tsx b/src/app/(jury)/jury/page.tsx index 9d26529..f041ce7 100644 --- a/src/app/(jury)/jury/page.tsx +++ b/src/app/(jury)/jury/page.tsx @@ -47,50 +47,62 @@ async function JuryDashboardContent() { return null } - // Get all assignments for this jury member - const assignments = await prisma.assignment.findMany({ - where: { userId }, - include: { - project: { - select: { - id: true, - title: true, - teamName: true, - country: true, + // Get assignments and grace periods in parallel + const [assignments, gracePeriods] = await Promise.all([ + prisma.assignment.findMany({ + where: { userId }, + include: { + project: { + select: { + id: true, + title: true, + teamName: true, + country: true, + }, }, - }, - round: { - select: { - id: true, - name: true, - status: true, - votingStartAt: true, - votingEndAt: true, - program: { - select: { - name: true, - year: true, + round: { + select: { + id: true, + name: true, + status: true, + votingStartAt: true, + votingEndAt: true, + program: { + select: { + name: true, + year: true, + }, + }, + }, + }, + evaluation: { + select: { + id: true, + status: true, + submittedAt: true, + criterionScoresJson: true, + form: { + select: { criteriaJson: true }, }, }, }, }, - evaluation: { - select: { - id: true, - status: true, - submittedAt: true, - criterionScoresJson: true, - form: { - select: { criteriaJson: true }, - }, - }, + orderBy: [ + { round: { votingEndAt: 'asc' } }, + { createdAt: 'asc' }, + ], + }), + prisma.gracePeriod.findMany({ + where: { + userId, + extendedUntil: { gte: new Date() }, }, - }, - orderBy: [ - { round: { votingEndAt: 'asc' } }, - { createdAt: 'asc' }, - ], - }) + select: { + roundId: true, + extendedUntil: true, + }, + }), + ]) // Calculate stats const totalAssignments = assignments.length @@ -122,18 +134,6 @@ async function JuryDashboardContent() { {} as Record ) - // 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() for (const gp of gracePeriods) { const existing = graceByRound.get(gp.roundId) diff --git a/src/app/(mentor)/error.tsx b/src/app/(mentor)/error.tsx index 91fb2b6..a4a924a 100644 --- a/src/app/(mentor)/error.tsx +++ b/src/app/(mentor)/error.tsx @@ -6,6 +6,7 @@ import type { Route } from 'next' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { AlertTriangle, RefreshCw, Users } from 'lucide-react' +import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery' export default function MentorError({ error, @@ -16,8 +17,14 @@ export default function MentorError({ }) { useEffect(() => { console.error('Mentor section error:', error) + + if (isChunkLoadError(error)) { + attemptChunkErrorRecovery('mentor') + } }, [error]) + const isChunk = isChunkLoadError(error) + return (
@@ -29,22 +36,32 @@ export default function MentorError({

- An error occurred while loading this page. Please try again or - return to your mentee dashboard. + {isChunk + ? '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.'}

- - + {isChunk ? ( + + ) : ( + <> + + + + )}
- {error.digest && ( + {!isChunk && error.digest && (

Error ID: {error.digest}

diff --git a/src/app/(observer)/error.tsx b/src/app/(observer)/error.tsx index ca8b108..643b9df 100644 --- a/src/app/(observer)/error.tsx +++ b/src/app/(observer)/error.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { AlertTriangle, RefreshCw, Eye } from 'lucide-react' +import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery' export default function ObserverError({ error, @@ -15,8 +16,14 @@ export default function ObserverError({ }) { useEffect(() => { console.error('Observer section error:', error) + + if (isChunkLoadError(error)) { + attemptChunkErrorRecovery('observer') + } }, [error]) + const isChunk = isChunkLoadError(error) + return (
@@ -28,22 +35,32 @@ export default function ObserverError({

- An error occurred while loading this page. Please try again or - return to the observer dashboard. + {isChunk + ? '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.'}

- - + {isChunk ? ( + + ) : ( + <> + + + + )}
- {error.digest && ( + {!isChunk && error.digest && (

Error ID: {error.digest}

diff --git a/src/app/(public)/error.tsx b/src/app/(public)/error.tsx index 27cd01f..afe4458 100644 --- a/src/app/(public)/error.tsx +++ b/src/app/(public)/error.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { AlertTriangle, RefreshCw, Home } from 'lucide-react' +import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery' export default function PublicError({ error, @@ -15,8 +16,14 @@ export default function PublicError({ }) { useEffect(() => { console.error('Public section error:', error) + + if (isChunkLoadError(error)) { + attemptChunkErrorRecovery('public') + } }, [error]) + const isChunk = isChunkLoadError(error) + return (
@@ -28,22 +35,32 @@ export default function PublicError({

- An error occurred while loading this page. Please try again or - return to the home page. + {isChunk + ? '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.'}

- - + {isChunk ? ( + + ) : ( + <> + + + + )}
- {error.digest && ( + {!isChunk && error.digest && (

Error ID: {error.digest}

diff --git a/src/app/(settings)/layout.tsx b/src/app/(settings)/layout.tsx index 9303296..7d38c89 100644 --- a/src/app/(settings)/layout.tsx +++ b/src/app/(settings)/layout.tsx @@ -7,6 +7,7 @@ const ROLE_DASHBOARDS: Record = { JURY_MEMBER: '/jury', MENTOR: '/mentor', OBSERVER: '/observer', + APPLICANT: '/applicant', } export default async function SettingsLayout({ diff --git a/src/app/error.tsx b/src/app/error.tsx index 2f01160..b22b648 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -3,7 +3,8 @@ import { useEffect } from 'react' import Link from 'next/link' 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({ error, @@ -14,8 +15,14 @@ export default function Error({ }) { useEffect(() => { console.error('Application error:', error) + + if (isChunkLoadError(error)) { + attemptChunkErrorRecovery('root') + } }, [error]) + const isChunk = isChunkLoadError(error) + return (
@@ -25,21 +32,31 @@ export default function Error({ Something went wrong

- An unexpected error occurred. Please try again or return to the - dashboard. + {isChunk + ? '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.'}

- {error.digest && ( + {!isChunk && error.digest && (

Error ID: {error.digest}

)}
- - + {isChunk ? ( + + ) : ( + <> + + + + )}
) diff --git a/src/app/page.tsx b/src/app/page.tsx index dfad0c2..aa7112a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -24,7 +24,7 @@ export default async function HomePage() { } else if (session.user.role === 'OBSERVER') { redirect('/observer') } else if (session.user.role === 'APPLICANT') { - redirect('/my-submission' as Route) + redirect('/applicant' as Route) } } diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 8f31cf0..92c229a 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -12,7 +12,7 @@ function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { - staleTime: 60 * 1000, // 1 minute + staleTime: 5 * 60 * 1000, // 5 minutes refetchOnWindowFocus: false, }, }, diff --git a/src/components/layouts/admin-sidebar.tsx b/src/components/layouts/admin-sidebar.tsx index e7217d6..9d3699b 100644 --- a/src/components/layouts/admin-sidebar.tsx +++ b/src/components/layouts/admin-sidebar.tsx @@ -41,6 +41,7 @@ import { EditionSelector } from '@/components/shared/edition-selector' import { useEdition } from '@/contexts/edition-context' import { UserAvatar } from '@/components/shared/user-avatar' import { NotificationBell } from '@/components/shared/notification-bell' +import { useSession } from 'next-auth/react' import { trpc } from '@/lib/trpc/client' interface AdminSidebarProps { @@ -145,7 +146,11 @@ const roleLabels: Record = { export function AdminSidebar({ user }: AdminSidebarProps) { const pathname = usePathname() 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 isSuperAdmin = user.role === 'SUPER_ADMIN' diff --git a/src/components/layouts/applicant-nav.tsx b/src/components/layouts/applicant-nav.tsx new file mode 100644 index 0000000..c0626ea --- /dev/null +++ b/src/components/layouts/applicant-nav.tsx @@ -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 ( + + ) +} diff --git a/src/components/layouts/role-nav.tsx b/src/components/layouts/role-nav.tsx index fdc6a43..3851fdd 100644 --- a/src/components/layouts/role-nav.tsx +++ b/src/components/layouts/role-nav.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react' import Link from 'next/link' import { usePathname } from 'next/navigation' -import { signOut } from 'next-auth/react' +import { signOut, useSession } from 'next-auth/react' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' 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) { const pathname = usePathname() 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 [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) diff --git a/src/components/shared/notification-bell.tsx b/src/components/shared/notification-bell.tsx index d95fd71..df1ac22 100644 --- a/src/components/shared/notification-bell.tsx +++ b/src/components/shared/notification-bell.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import Link from 'next/link' import type { Route } from 'next' import { usePathname } from 'next/navigation' +import { useSession } from 'next-auth/react' import { trpc } from '@/lib/trpc/client' import { cn, formatRelativeTime } from '@/lib/utils' import { Button } from '@/components/ui/button' @@ -212,6 +213,8 @@ function NotificationItem({ export function NotificationBell() { const [open, setOpen] = useState(false) const pathname = usePathname() + const { status: sessionStatus } = useSession() + const isAuthenticated = sessionStatus === 'authenticated' // Derive the role-based path prefix from the current route const pathPrefix = pathname.startsWith('/admin') @@ -222,16 +225,20 @@ export function NotificationBell() { ? '/mentor' : pathname.startsWith('/observer') ? '/observer' - : '' + : pathname.startsWith('/applicant') + ? '/applicant' + : '' const { data: countData } = trpc.notification.getUnreadCount.useQuery( undefined, { + enabled: isAuthenticated, refetchInterval: 30000, // Refetch every 30 seconds } ) const { data: hasUrgent } = trpc.notification.hasUrgent.useQuery(undefined, { + enabled: isAuthenticated, refetchInterval: 30000, }) @@ -241,7 +248,7 @@ export function NotificationBell() { limit: 20, }, { - enabled: open, // Only fetch when popover is open + enabled: open && isAuthenticated, // Only fetch when popover is open and authenticated } ) diff --git a/src/lib/auth-redirect.ts b/src/lib/auth-redirect.ts index a696d18..8745896 100644 --- a/src/lib/auth-redirect.ts +++ b/src/lib/auth-redirect.ts @@ -9,6 +9,7 @@ const ROLE_DASHBOARDS: Record = { JURY_MEMBER: '/jury', MENTOR: '/mentor', OBSERVER: '/observer', + APPLICANT: '/applicant', } export async function requireRole(...allowedRoles: UserRole[]) { diff --git a/src/lib/chunk-error-recovery.ts b/src/lib/chunk-error-recovery.ts new file mode 100644 index 0000000..87e1486 --- /dev/null +++ b/src/lib/chunk-error-recovery.ts @@ -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 +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 8194bf1..fe72dd6 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -4,9 +4,19 @@ const globalForPrisma = globalThis as unknown as { 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 = globalForPrisma.prisma ?? new PrismaClient({ + datasourceUrl: getDatasourceUrl(), log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts index a4b4d51..0c2f41f 100644 --- a/src/server/routers/_app.ts +++ b/src/server/routers/_app.ts @@ -35,6 +35,7 @@ import { messageRouter } from './message' import { webhookRouter } from './webhook' import { projectPoolRouter } from './project-pool' import { wizardTemplateRouter } from './wizard-template' +import { dashboardRouter } from './dashboard' /** * Root tRPC router that combines all domain routers @@ -76,6 +77,7 @@ export const appRouter = router({ webhook: webhookRouter, projectPool: projectPoolRouter, wizardTemplate: wizardTemplateRouter, + dashboard: dashboardRouter, }) export type AppRouter = typeof appRouter diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index 0fa7031..1811754 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -1030,4 +1030,186 @@ export const applicantRouter = router({ 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() + 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 | 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, + } + }), }) diff --git a/src/server/routers/dashboard.ts b/src/server/routers/dashboard.ts new file mode 100644 index 0000000..9b4ec4e --- /dev/null +++ b/src/server/routers/dashboard.ts @@ -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, + } + }), +}) diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 344b302..45f6840 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -681,7 +681,7 @@ export const mentorRouter = router({ type: 'MENTOR_MESSAGE', title: 'New Message from Mentor', message: `${ctx.user.name || 'Your mentor'} sent you a message`, - linkUrl: `/my-submission/${input.projectId}`, + linkUrl: `/applicant/mentor`, linkLabel: 'View Message', priority: 'normal', metadata: { diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index 2414e79..99f3414 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -1164,4 +1164,121 @@ export const projectRouter = router({ 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, + } + }), })