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',
|
||||
typedRoutes: true,
|
||||
serverExternalPackages: ['@prisma/client', 'minio'],
|
||||
experimental: {
|
||||
optimizePackageImports: ['lucide-react'],
|
||||
},
|
||||
images: {
|
||||
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 }) {
|
||||
// Fetch project data
|
||||
const { data: project, isLoading } = trpc.project.get.useQuery({
|
||||
// Fetch project + assignments + stats in a single combined query
|
||||
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery({
|
||||
id: projectId,
|
||||
})
|
||||
|
||||
const project = fullDetail?.project
|
||||
const assignments = fullDetail?.assignments
|
||||
const stats = fullDetail?.stats
|
||||
|
||||
// Fetch files (flat list for backward compatibility)
|
||||
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
|
||||
|
||||
|
|
@ -93,16 +97,6 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
{ 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()
|
||||
|
||||
if (isLoading) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Link from 'next/link'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, LayoutDashboard } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function AdminError({
|
||||
error,
|
||||
|
|
@ -15,8 +16,14 @@ export default function AdminError({
|
|||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Admin section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('admin')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
|
|
@ -28,10 +35,23 @@ export default function AdminError({
|
|||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
An error occurred while loading this admin page. Please try again or
|
||||
return to the dashboard.
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this admin page. Please try again or return to the dashboard.'}
|
||||
</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">
|
||||
{isChunk ? (
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={reset} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
|
|
@ -42,12 +62,9 @@ export default function AdminError({
|
|||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, ClipboardList } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function JuryError({
|
||||
error,
|
||||
|
|
@ -15,8 +16,14 @@ export default function JuryError({
|
|||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Jury section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('jury')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
|
|
@ -28,10 +35,18 @@ export default function JuryError({
|
|||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
An error occurred while loading this page. Please try again or
|
||||
return to your assignments.
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this page. Please try again or return to your assignments.'}
|
||||
</p>
|
||||
<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">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
|
|
@ -42,8 +57,10 @@ export default function JuryError({
|
|||
My Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{error.digest && (
|
||||
{!isChunk && error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -47,8 +47,9 @@ async function JuryDashboardContent() {
|
|||
return null
|
||||
}
|
||||
|
||||
// Get all assignments for this jury member
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
// Get assignments and grace periods in parallel
|
||||
const [assignments, gracePeriods] = await Promise.all([
|
||||
prisma.assignment.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
project: {
|
||||
|
|
@ -90,7 +91,18 @@ async function JuryDashboardContent() {
|
|||
{ round: { votingEndAt: 'asc' } },
|
||||
{ createdAt: 'asc' },
|
||||
],
|
||||
})
|
||||
}),
|
||||
prisma.gracePeriod.findMany({
|
||||
where: {
|
||||
userId,
|
||||
extendedUntil: { gte: new Date() },
|
||||
},
|
||||
select: {
|
||||
roundId: true,
|
||||
extendedUntil: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
// Calculate stats
|
||||
const totalAssignments = assignments.length
|
||||
|
|
@ -122,18 +134,6 @@ async function JuryDashboardContent() {
|
|||
{} 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>()
|
||||
for (const gp of gracePeriods) {
|
||||
const existing = graceByRound.get(gp.roundId)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { Route } from 'next'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, Users } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function MentorError({
|
||||
error,
|
||||
|
|
@ -16,8 +17,14 @@ export default function MentorError({
|
|||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Mentor section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('mentor')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
|
|
@ -29,10 +36,18 @@ export default function MentorError({
|
|||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
An error occurred while loading this page. Please try again or
|
||||
return to your mentee dashboard.
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this page. Please try again or return to your mentee dashboard.'}
|
||||
</p>
|
||||
<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">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
|
|
@ -43,8 +58,10 @@ export default function MentorError({
|
|||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{error.digest && (
|
||||
{!isChunk && error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Link from 'next/link'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, Eye } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function ObserverError({
|
||||
error,
|
||||
|
|
@ -15,8 +16,14 @@ export default function ObserverError({
|
|||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Observer section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('observer')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
|
|
@ -28,10 +35,18 @@ export default function ObserverError({
|
|||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
An error occurred while loading this page. Please try again or
|
||||
return to the observer dashboard.
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this page. Please try again or return to the observer dashboard.'}
|
||||
</p>
|
||||
<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">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
|
|
@ -42,8 +57,10 @@ export default function ObserverError({
|
|||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{error.digest && (
|
||||
{!isChunk && error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Link from 'next/link'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function PublicError({
|
||||
error,
|
||||
|
|
@ -15,8 +16,14 @@ export default function PublicError({
|
|||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Public section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('public')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
|
|
@ -28,10 +35,18 @@ export default function PublicError({
|
|||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
An error occurred while loading this page. Please try again or
|
||||
return to the home page.
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this page. Please try again or return to the home page.'}
|
||||
</p>
|
||||
<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">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
|
|
@ -42,8 +57,10 @@ export default function PublicError({
|
|||
Home
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{error.digest && (
|
||||
{!isChunk && error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const ROLE_DASHBOARDS: Record<string, string> = {
|
|||
JURY_MEMBER: '/jury',
|
||||
MENTOR: '/mentor',
|
||||
OBSERVER: '/observer',
|
||||
APPLICANT: '/applicant',
|
||||
}
|
||||
|
||||
export default async function SettingsLayout({
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
|
|
@ -14,8 +15,14 @@ export default function Error({
|
|||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Application error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('root')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<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">
|
||||
|
|
@ -25,21 +32,31 @@ export default function Error({
|
|||
Something went wrong
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-body text-muted-foreground">
|
||||
An unexpected error occurred. Please try again or return to the
|
||||
dashboard.
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An unexpected error occurred. Please try again or return to the dashboard.'}
|
||||
</p>
|
||||
{error.digest && (
|
||||
{!isChunk && error.digest && (
|
||||
<p className="mt-2 text-tiny text-muted-foreground/60">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
<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()}>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" asChild>
|
||||
<Link href="/">Return to Dashboard</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export default async function HomePage() {
|
|||
} else if (session.user.role === 'OBSERVER') {
|
||||
redirect('/observer')
|
||||
} else if (session.user.role === 'APPLICANT') {
|
||||
redirect('/my-submission' as Route)
|
||||
redirect('/applicant' as Route)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ function makeQueryClient() {
|
|||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import { EditionSelector } from '@/components/shared/edition-selector'
|
|||
import { useEdition } from '@/contexts/edition-context'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { NotificationBell } from '@/components/shared/notification-bell'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
||||
interface AdminSidebarProps {
|
||||
|
|
@ -145,7 +146,11 @@ const roleLabels: Record<string, string> = {
|
|||
export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
|
||||
const { status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
const { currentEdition } = useEdition()
|
||||
|
||||
const isSuperAdmin = user.role === 'SUPER_ADMIN'
|
||||
|
|
|
|||
|
|
@ -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 Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { signOut, useSession } from 'next-auth/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
|
|
@ -50,7 +50,11 @@ function isNavItemActive(pathname: string, href: string, basePath: string): bool
|
|||
export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: RoleNavProps) {
|
||||
const pathname = usePathname()
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
|
||||
const { status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { cn, formatRelativeTime } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -212,6 +213,8 @@ function NotificationItem({
|
|||
export function NotificationBell() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const { status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
|
||||
// Derive the role-based path prefix from the current route
|
||||
const pathPrefix = pathname.startsWith('/admin')
|
||||
|
|
@ -222,16 +225,20 @@ export function NotificationBell() {
|
|||
? '/mentor'
|
||||
: pathname.startsWith('/observer')
|
||||
? '/observer'
|
||||
: pathname.startsWith('/applicant')
|
||||
? '/applicant'
|
||||
: ''
|
||||
|
||||
const { data: countData } = trpc.notification.getUnreadCount.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
}
|
||||
)
|
||||
|
||||
const { data: hasUrgent } = trpc.notification.hasUrgent.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
|
||||
|
|
@ -241,7 +248,7 @@ export function NotificationBell() {
|
|||
limit: 20,
|
||||
},
|
||||
{
|
||||
enabled: open, // Only fetch when popover is open
|
||||
enabled: open && isAuthenticated, // Only fetch when popover is open and authenticated
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const ROLE_DASHBOARDS: Record<string, string> = {
|
|||
JURY_MEMBER: '/jury',
|
||||
MENTOR: '/mentor',
|
||||
OBSERVER: '/observer',
|
||||
APPLICANT: '/applicant',
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function getDatasourceUrl(): string | undefined {
|
||||
const url = process.env.DATABASE_URL
|
||||
if (!url) return undefined
|
||||
// Append connection pool params if not already present
|
||||
if (url.includes('connection_limit')) return url
|
||||
const separator = url.includes('?') ? '&' : '?'
|
||||
return `${url}${separator}connection_limit=20&pool_timeout=10`
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
datasourceUrl: getDatasourceUrl(),
|
||||
log:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? ['query', 'error', 'warn']
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { messageRouter } from './message'
|
|||
import { webhookRouter } from './webhook'
|
||||
import { projectPoolRouter } from './project-pool'
|
||||
import { wizardTemplateRouter } from './wizard-template'
|
||||
import { dashboardRouter } from './dashboard'
|
||||
|
||||
/**
|
||||
* Root tRPC router that combines all domain routers
|
||||
|
|
@ -76,6 +77,7 @@ export const appRouter = router({
|
|||
webhook: webhookRouter,
|
||||
projectPool: projectPoolRouter,
|
||||
wizardTemplate: wizardTemplateRouter,
|
||||
dashboard: dashboardRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
|
|
|
|||
|
|
@ -1030,4 +1030,186 @@ export const applicantRouter = router({
|
|||
|
||||
return messages
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the applicant's dashboard data: their project (latest edition),
|
||||
* team members, open rounds for document submission, and status timeline.
|
||||
*/
|
||||
getMyDashboard: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Only applicants can access this',
|
||||
})
|
||||
}
|
||||
|
||||
// Find the applicant's project (most recent, from active edition if possible)
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true, status: true } },
|
||||
},
|
||||
},
|
||||
files: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
teamMembers: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true, email: true, status: true },
|
||||
},
|
||||
},
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
},
|
||||
submittedBy: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
mentorAssignment: {
|
||||
include: {
|
||||
mentor: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
wonAwards: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
return { project: null, openRounds: [], timeline: [], currentStatus: null }
|
||||
}
|
||||
|
||||
const currentStatus = project.status ?? 'SUBMITTED'
|
||||
|
||||
// Fetch status history
|
||||
const statusHistory = await ctx.prisma.projectStatusHistory.findMany({
|
||||
where: { projectId: project.id },
|
||||
orderBy: { changedAt: 'asc' },
|
||||
select: { status: true, changedAt: true },
|
||||
})
|
||||
|
||||
const statusDateMap = new Map<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',
|
||||
title: 'New Message from Mentor',
|
||||
message: `${ctx.user.name || 'Your mentor'} sent you a message`,
|
||||
linkUrl: `/my-submission/${input.projectId}`,
|
||||
linkUrl: `/applicant/mentor`,
|
||||
linkLabel: 'View Message',
|
||||
priority: 'normal',
|
||||
metadata: {
|
||||
|
|
|
|||
|
|
@ -1164,4 +1164,121 @@ export const projectRouter = router({
|
|||
|
||||
return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage) }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get full project detail with assignments and evaluation stats in one call.
|
||||
* Reduces client-side waterfall by combining project.get + assignment.listByProject + evaluation.getProjectStats.
|
||||
*/
|
||||
getFullDetail: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [projectRaw, projectTags, assignments, submittedEvaluations] = await Promise.all([
|
||||
ctx.prisma.project.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
files: true,
|
||||
round: true,
|
||||
teamMembers: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true },
|
||||
},
|
||||
},
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
},
|
||||
mentorAssignment: {
|
||||
include: {
|
||||
mentor: {
|
||||
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ctx.prisma.projectTag.findMany({
|
||||
where: { projectId: input.id },
|
||||
include: { tag: { select: { id: true, name: true, category: true, color: true } } },
|
||||
orderBy: { confidence: 'desc' },
|
||||
}).catch(() => [] as { id: string; projectId: string; tagId: string; confidence: number; tag: { id: string; name: string; category: string | null; color: string | null } }[]),
|
||||
ctx.prisma.assignment.findMany({
|
||||
where: { projectId: input.id },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true } },
|
||||
evaluation: { select: { status: true, submittedAt: true, globalScore: true, binaryDecision: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
status: 'SUBMITTED',
|
||||
assignment: { projectId: input.id },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
// Compute evaluation stats
|
||||
let stats = null
|
||||
if (submittedEvaluations.length > 0) {
|
||||
const globalScores = submittedEvaluations
|
||||
.map((e) => e.globalScore)
|
||||
.filter((s): s is number => s !== null)
|
||||
const yesVotes = submittedEvaluations.filter((e) => e.binaryDecision === true).length
|
||||
stats = {
|
||||
totalEvaluations: submittedEvaluations.length,
|
||||
averageGlobalScore: globalScores.length > 0
|
||||
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
|
||||
: null,
|
||||
minScore: globalScores.length > 0 ? Math.min(...globalScores) : null,
|
||||
maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null,
|
||||
yesVotes,
|
||||
noVotes: submittedEvaluations.length - yesVotes,
|
||||
yesPercentage: (yesVotes / submittedEvaluations.length) * 100,
|
||||
}
|
||||
}
|
||||
|
||||
// Attach avatar URLs in parallel
|
||||
const [teamMembersWithAvatars, assignmentsWithAvatars, mentorWithAvatar] = await Promise.all([
|
||||
Promise.all(
|
||||
projectRaw.teamMembers.map(async (member) => ({
|
||||
...member,
|
||||
user: {
|
||||
...member.user,
|
||||
avatarUrl: await getUserAvatarUrl(member.user.profileImageKey, member.user.profileImageProvider),
|
||||
},
|
||||
}))
|
||||
),
|
||||
Promise.all(
|
||||
assignments.map(async (a) => ({
|
||||
...a,
|
||||
user: {
|
||||
...a.user,
|
||||
avatarUrl: await getUserAvatarUrl(a.user.profileImageKey, a.user.profileImageProvider),
|
||||
},
|
||||
}))
|
||||
),
|
||||
projectRaw.mentorAssignment
|
||||
? (async () => ({
|
||||
...projectRaw.mentorAssignment!,
|
||||
mentor: {
|
||||
...projectRaw.mentorAssignment!.mentor,
|
||||
avatarUrl: await getUserAvatarUrl(
|
||||
projectRaw.mentorAssignment!.mentor.profileImageKey,
|
||||
projectRaw.mentorAssignment!.mentor.profileImageProvider
|
||||
),
|
||||
},
|
||||
}))()
|
||||
: Promise.resolve(null),
|
||||
])
|
||||
|
||||
return {
|
||||
project: {
|
||||
...projectRaw,
|
||||
projectTags,
|
||||
teamMembers: teamMembersWithAvatars,
|
||||
mentorAssignment: mentorWithAvatar,
|
||||
},
|
||||
assignments: assignmentsWithAvatars,
|
||||
stats,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue