Performance optimization, applicant portal, and missing DB migration
Build and Push Docker Image / build (push) Successful in 14m6s
Details
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:
parent
09091d7c08
commit
98f4a957cc
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
-- =============================================================================
|
||||||
|
|
@ -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} — {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 · {round._count.assignments} assignments
|
||||||
|
{round.totalEvals > 0 && (
|
||||||
|
<> · {round.evalPercent}% evaluated</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{round.votingStartAt && round.votingEndAt && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Voting: {formatDateOnly(round.votingStartAt)} – {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} — {deadline.roundName}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||||
|
{formatDateOnly(deadline.date)} · 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
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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), [])
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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[]) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue