diff --git a/src/app/(admin)/admin/reports/page.tsx b/src/app/(admin)/admin/reports/page.tsx
index 4c6f25a..7e673ce 100644
--- a/src/app/(admin)/admin/reports/page.tsx
+++ b/src/app/(admin)/admin/reports/page.tsx
@@ -657,7 +657,7 @@ function CrossStageTab() {
className="cursor-pointer text-sm py-1.5 px-3"
onClick={() => toggleRound(stage.id)}
>
- {stage.programName} - {stage.name}
+ {stage.name}
)
})}
@@ -740,7 +740,7 @@ function JurorConsistencyTab() {
))}
{stages.map((stage) => (
- {stage.programName} - {stage.name}
+ {stage.name}
))}
@@ -814,7 +814,7 @@ function DiversityTab() {
))}
{stages.map((stage) => (
- {stage.programName} - {stage.name}
+ {stage.name}
))}
@@ -992,7 +992,7 @@ export default function ReportsPage() {
{pdfStages.map((stage) => (
- {stage.programName} - {stage.name}
+ {stage.name}
))}
diff --git a/src/app/(observer)/observer/projects/[projectId]/page.tsx b/src/app/(observer)/observer/projects/[projectId]/page.tsx
new file mode 100644
index 0000000..8aa4689
--- /dev/null
+++ b/src/app/(observer)/observer/projects/[projectId]/page.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+import { ObserverProjectDetail } from '@/components/observer/observer-project-detail'
+
+export const metadata: Metadata = { title: 'Project Detail' }
+export const dynamic = 'force-dynamic'
+
+export default async function ObserverProjectDetailPage({
+ params,
+}: {
+ params: Promise<{ projectId: string }>
+}) {
+ const { projectId } = await params
+
+ return
+}
diff --git a/src/app/(observer)/observer/reports/page.tsx b/src/app/(observer)/observer/reports/page.tsx
index 748e0b2..776e349 100644
--- a/src/app/(observer)/observer/reports/page.tsx
+++ b/src/app/(observer)/observer/reports/page.tsx
@@ -508,9 +508,9 @@ function CrossStageTab() {
size="sm"
pressed={selectedRoundIds.includes(stage.id)}
onPressedChange={() => toggleRound(stage.id)}
- aria-label={`${stage.programName} - ${stage.name}`}
+ aria-label={stage.name}
>
- {stage.programName} - {stage.name}
+ {stage.name}
))}
@@ -644,7 +644,7 @@ export default function ObserverReportsPage() {
))}
{stages.map((stage) => (
- {stage.programName} - {stage.name}{stage.roundType ? ` (${ROUND_TYPE_LABELS[stage.roundType] || stage.roundType})` : ''}
+ {stage.name}{stage.roundType ? ` (${ROUND_TYPE_LABELS[stage.roundType] || stage.roundType})` : ''}
))}
diff --git a/src/components/charts/chart-theme.ts b/src/components/charts/chart-theme.ts
index a5378af..a225813 100644
--- a/src/components/charts/chart-theme.ts
+++ b/src/components/charts/chart-theme.ts
@@ -36,7 +36,7 @@ export const STATUS_COLORS: Record = {
export const STATUS_LABELS: Record = {
SUBMITTED: 'Submitted',
ELIGIBLE: 'Eligible',
- ASSIGNED: 'Assigned',
+ ASSIGNED: 'Special Award',
SEMIFINALIST: 'Semi-finalist',
FINALIST: 'Finalist',
REJECTED: 'Rejected',
diff --git a/src/components/charts/diversity-metrics.tsx b/src/components/charts/diversity-metrics.tsx
index 44922e5..78d2f22 100644
--- a/src/components/charts/diversity-metrics.tsx
+++ b/src/components/charts/diversity-metrics.tsx
@@ -61,7 +61,7 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
: topCountries
const nivoPieData = countryPieData.map((c) => ({
- id: c.country,
+ id: c.country === 'Others' ? 'Others' : c.country.toUpperCase(),
label: getCountryName(c.country),
value: c.count,
}))
diff --git a/src/components/charts/project-rankings.tsx b/src/components/charts/project-rankings.tsx
index df02806..909ff9d 100644
--- a/src/components/charts/project-rankings.tsx
+++ b/src/components/charts/project-rankings.tsx
@@ -30,13 +30,13 @@ export function ProjectRankingsChart({
data,
limit = 20,
}: ProjectRankingsProps) {
- if (!data?.length) return null
-
- const scoredData = data.filter(
+ const scoredData = (data ?? []).filter(
(d): d is ProjectRankingData & { averageScore: number } =>
d.averageScore !== null,
)
+ if (!scoredData.length) return null
+
const averageScore =
scoredData.length > 0
? scoredData.reduce((sum, d) => sum + d.averageScore, 0) /
diff --git a/src/components/observer/observer-dashboard-content.tsx b/src/components/observer/observer-dashboard-content.tsx
index f669cc6..00239fd 100644
--- a/src/components/observer/observer-dashboard-content.tsx
+++ b/src/components/observer/observer-dashboard-content.tsx
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
+import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
@@ -150,26 +151,6 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
- {/* Observer Notice */}
-
-
-
-
-
-
-
-
Observer Mode
-
- Read-Only
-
-
-
- You have read-only access to view platform statistics and reports.
-
-
-
-
-
{/* Round Filter */}
@@ -181,7 +162,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
All Rounds
{rounds.map((round) => (
- {round.programName} - {round.name}{round.roundType ? ` (${round.roundType.replace(/_/g, ' ')})` : ''}
+ {round.name}{round.roundType ? ` (${round.roundType.replace(/_/g, ' ')})` : ''}
))}
@@ -364,7 +345,6 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
Title
-
Team
Round
Status
@@ -381,11 +361,12 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
{projectsData.projects.map((project) => (
-
-
- {project.title}
+ window.location.href = `/observer/projects/${project.id}`}>
+
+ e.stopPropagation()}>
+ {project.title}
+
- {project.teamName || '-'}
{project.roundName}
@@ -411,26 +392,25 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
{/* Mobile Cards */}
{projectsData.projects.map((project) => (
-
-
-
- {project.teamName && (
- {project.teamName}
- )}
-
-
- {project.roundName}
-
-
-
Score: {project.averageScore !== null ? project.averageScore.toFixed(2) : '-'}
-
{project.evaluationCount} eval{project.evaluationCount !== 1 ? 's' : ''}
+
+
+
+
-
-
-
+
+
+ {project.roundName}
+
+
+ Score: {project.averageScore !== null ? project.averageScore.toFixed(2) : '-'}
+ {project.evaluationCount} eval{project.evaluationCount !== 1 ? 's' : ''}
+
+
+
+
+
))}
diff --git a/src/components/observer/observer-project-detail.tsx b/src/components/observer/observer-project-detail.tsx
new file mode 100644
index 0000000..6a9f9a7
--- /dev/null
+++ b/src/components/observer/observer-project-detail.tsx
@@ -0,0 +1,1051 @@
+'use client'
+
+import { useState } from 'react'
+import Link from 'next/link'
+import type { Route } from 'next'
+import { trpc } from '@/lib/trpc/client'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Separator } from '@/components/ui/separator'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from '@/components/ui/sheet'
+import { FileViewer } from '@/components/shared/file-viewer'
+import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
+import { UserAvatar } from '@/components/shared/user-avatar'
+import { StatusBadge } from '@/components/shared/status-badge'
+import { AnimatedCard } from '@/components/shared/animated-container'
+import {
+ ArrowLeft,
+ AlertCircle,
+ Users,
+ FileText,
+ Calendar,
+ CheckCircle2,
+ Circle,
+ BarChart3,
+ ThumbsUp,
+ ThumbsDown,
+ MapPin,
+ Waves,
+ GraduationCap,
+ Heart,
+ Crown,
+ Eye,
+ MessageSquare,
+} from 'lucide-react'
+import { formatDate, formatDateOnly } from '@/lib/utils'
+
+export function ObserverProjectDetail({ projectId }: { projectId: string }) {
+ const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery(
+ { id: projectId },
+ { refetchInterval: 30_000 },
+ )
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const [selectedAssignment, setSelectedAssignment] = useState(null)
+
+ if (isLoading) {
+ return
+ }
+
+ if (!data) {
+ return (
+
+
+
+
+
+ Project Not Found
+
+
+
+
+ )
+ }
+
+ const { project, assignments, stats, competitionRounds, allRequirements } = data
+
+ return (
+
+ {/* Back */}
+
+
+ {/* Header */}
+
+
+
+
+
+ {project.title}
+
+
+
+ {project.teamName && (
+
{project.teamName}
+ )}
+
+
+
+
+
+ {/* Stats */}
+ {stats && (
+
+
+
+
+
+ Average Score
+
+
+
+
+
+
+
+ {stats.averageGlobalScore?.toFixed(1) || '-'}
+
+
+ Range: {stats.minScore ?? '-'} - {stats.maxScore ?? '-'} ({stats.totalEvaluations} evaluation{stats.totalEvaluations !== 1 ? 's' : ''})
+
+
+
+
+
+
+
+ Recommendations
+
+
+
+
+
+
+
+ {stats.yesPercentage?.toFixed(0) || 0}%
+
+
+ {stats.yesVotes} yes / {stats.noVotes} no
+
+
+
+
+
+ )}
+
+ {/* Project Info */}
+
+
+
+
+
+
+
+ Project Information
+
+
+
+ {/* Category & Ocean Issue badges */}
+
+ {project.competitionCategory && (
+
+
+ {project.competitionCategory === 'STARTUP'
+ ? 'Start-up'
+ : 'Business Concept'}
+
+ )}
+ {project.oceanIssue && (
+
+
+ {project.oceanIssue.replace(/_/g, ' ')}
+
+ )}
+ {project.wantsMentorship && (
+
+
+ Wants Mentorship
+
+ )}
+
+
+ {project.description && (
+
+
+ Description
+
+
+ {project.description}
+
+
+ )}
+
+ {/* Location & Institution */}
+
+ {(project.country || project.geographicZone) && (
+
+
+
+
+ Location
+
+
+ {project.geographicZone || project.country}
+
+
+
+ )}
+ {project.institution && (
+
+
+
+
+ Institution
+
+
{project.institution}
+
+
+ )}
+ {project.foundedAt && (
+
+
+
+
+ Founded
+
+
{formatDateOnly(project.foundedAt)}
+
+
+ )}
+
+
+ {/* Submission URLs */}
+ {(project.phase1SubmissionUrl || project.phase2SubmissionUrl) && (
+
+
+ Submission Links
+
+
+
+ )}
+
+ {/* Expertise Tags */}
+ {project.projectTags && project.projectTags.length > 0 && (
+
+
+ Expertise Tags
+
+
+ {project.projectTags.map((pt) => (
+
+ {pt.tag.name}
+ {pt.confidence < 1 && (
+
+ {Math.round(pt.confidence * 100)}%
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+
+ Created:{' '}
+ {formatDateOnly(project.createdAt)}
+
+
+ Updated:{' '}
+ {formatDateOnly(project.updatedAt)}
+
+
+
+
+
+
+ {/* Team Members */}
+ {project.teamMembers && project.teamMembers.length > 0 && (
+
+
+
+
+
+
+
+ Team Members ({project.teamMembers.length})
+
+
+
+
+ {project.teamMembers.map(
+ (member: {
+ id: string
+ role: string
+ title: string | null
+ user: {
+ id: string
+ name: string | null
+ email: string
+ avatarUrl?: string | null
+ }
+ }) => (
+
+ {member.role === 'LEAD' ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {member.user.name || 'Unnamed'}
+
+
+ {member.role === 'LEAD'
+ ? 'Lead'
+ : member.role === 'ADVISOR'
+ ? 'Advisor'
+ : 'Member'}
+
+
+
+ {member.user.email}
+
+ {member.title && (
+
+ {member.title}
+
+ )}
+
+
+ ),
+ )}
+
+
+
+
+ )}
+
+ {/* Files Section */}
+
+
+
+
+
+
+
+ Files
+
+
+ Project documents organized by competition round
+
+
+
+ {/* Requirements organized by round */}
+ {competitionRounds.length > 0 && allRequirements.length > 0 ? (
+ <>
+ {competitionRounds.map((round) => {
+ const roundRequirements = allRequirements.filter(
+ (req) => req.roundId === round.id,
+ )
+ if (roundRequirements.length === 0) return null
+
+ return (
+
+
+
+ {round.name}
+
+
+ {roundRequirements.length} requirement
+ {roundRequirements.length !== 1 ? 's' : ''}
+
+
+
+ {roundRequirements.map((req) => {
+ const fulfilledFile = project.files?.find(
+ (f) => f.requirementId === req.id,
+ )
+ const isFulfilled = !!fulfilledFile
+
+ return (
+
+
+ {isFulfilled ? (
+
+ ) : (
+
+ )}
+
+
+
+ {req.name}
+
+ {req.isRequired && (
+
+ Required
+
+ )}
+
+ {req.description && (
+
+ {req.description}
+
+ )}
+
+ {req.acceptedMimeTypes?.length > 0 && (
+
+ {req.acceptedMimeTypes
+ .map((mime) => {
+ if (
+ mime === 'application/pdf'
+ )
+ return 'PDF'
+ if (mime === 'image/*')
+ return 'Images'
+ if (mime === 'video/*')
+ return 'Video'
+ if (
+ mime.includes(
+ 'wordprocessing',
+ )
+ )
+ return 'Word'
+ if (
+ mime.includes('spreadsheet')
+ )
+ return 'Excel'
+ if (
+ mime.includes('presentation')
+ )
+ return 'PowerPoint'
+ return (
+ mime.split('/')[1] || mime
+ )
+ })
+ .join(', ')}
+
+ )}
+ {req.maxSizeMB && (
+
+ Max {req.maxSizeMB}MB
+
+ )}
+
+ {isFulfilled && fulfilledFile && (
+
+ {fulfilledFile.fileName}
+
+ )}
+
+
+ {!isFulfilled && (
+
+ Missing
+
+ )}
+
+ )
+ },
+ )}
+
+
+ )
+ },
+ )}
+
+ >
+ ) : null}
+
+ {/* All uploaded files viewer */}
+ {project.files && project.files.length > 0 ? (
+
+
+ {allRequirements.length > 0
+ ? 'All Uploaded Files'
+ : 'Uploaded Files'}
+
+
({
+ id: f.id,
+ fileName: f.fileName,
+ fileType: f.fileType as
+ | 'EXEC_SUMMARY'
+ | 'PRESENTATION'
+ | 'VIDEO'
+ | 'OTHER'
+ | 'BUSINESS_PLAN'
+ | 'VIDEO_PITCH'
+ | 'SUPPORTING_DOC',
+ mimeType: f.mimeType,
+ size: f.size,
+ bucket: f.bucket,
+ objectKey: f.objectKey,
+ pageCount: f.pageCount,
+ textPreview: f.textPreview,
+ detectedLang: f.detectedLang,
+ langConfidence: f.langConfidence,
+ analyzedAt: f.analyzedAt
+ ? String(f.analyzedAt)
+ : null,
+ requirementId: f.requirementId,
+ requirement: f.requirement
+ ? {
+ id: f.requirement.id,
+ name: f.requirement.name,
+ description: f.requirement.description,
+ isRequired: f.requirement.isRequired,
+ }
+ : null,
+ }))}
+ />
+
+ ) : (
+
+
+
+ No files uploaded yet
+
+
+ )}
+
+
+
+
+ {/* Jury Assignments & Evaluations */}
+ {assignments && assignments.length > 0 && (
+
+
+
+
+
+
+
+ Jury Evaluations
+
+
+ {
+ assignments.filter(
+ (a) => a.evaluation?.status === 'SUBMITTED',
+ ).length
+ }{' '}
+ of {assignments.length} evaluations completed
+
+
+
+ {/* Desktop Table */}
+
+
+
+
+ Juror
+ Round
+ Status
+ Score
+ Decision
+
+
+
+
+ {assignments.map((assignment) => (
+ {
+ if (
+ assignment.evaluation?.status === 'SUBMITTED'
+ ) {
+ setSelectedAssignment(assignment)
+ }
+ }}
+ >
+
+
+
+
+
+ {assignment.user.name || 'Unnamed'}
+
+
+ {assignment.user.email}
+
+
+
+
+
+
+ {assignment.round.name}
+
+
+
+
+
+
+ {assignment.evaluation?.globalScore != null ? (
+
+ {assignment.evaluation.globalScore}/10
+
+ ) : (
+ -
+ )}
+
+
+ {assignment.evaluation?.binaryDecision != null ? (
+ assignment.evaluation.binaryDecision ? (
+
+
+ Yes
+
+ ) : (
+
+
+ No
+
+ )
+ ) : (
+ -
+ )}
+
+
+ {assignment.evaluation?.status === 'SUBMITTED' && (
+
+ )}
+
+
+ ),
+ )}
+
+
+
+
+ {/* Mobile Cards */}
+
+ {assignments.map((assignment) => (
+
+ ),
+ )}
+
+
+
+
+ )}
+
+ {/* Evaluation Detail Sheet */}
+
{
+ if (!open) setSelectedAssignment(null)
+ }}
+ />
+
+ )
+}
+
+function EvaluationDetailSheet({
+ assignment,
+ open,
+ onOpenChange,
+}: {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ assignment: any
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}) {
+ if (!assignment?.evaluation) return null
+
+ const ev = assignment.evaluation
+ const criterionScores = (ev.criterionScoresJson || {}) as Record<
+ string,
+ number | boolean | string
+ >
+ const hasScores = Object.keys(criterionScores).length > 0
+
+ // Get evaluation form for criterion labels
+ const roundId = assignment.roundId as string | undefined
+ const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
+ { roundId: roundId ?? '' },
+ { enabled: !!roundId && open },
+ )
+
+ const criteriaMap = new Map<
+ string,
+ {
+ label: string
+ type: string
+ trueLabel?: string
+ falseLabel?: string
+ }
+ >()
+ if (activeForm?.criteriaJson) {
+ for (const c of activeForm.criteriaJson as Array<{
+ id: string
+ label: string
+ type?: string
+ trueLabel?: string
+ falseLabel?: string
+ }>) {
+ criteriaMap.set(c.id, {
+ label: c.label,
+ type: c.type || 'numeric',
+ trueLabel: c.trueLabel,
+ falseLabel: c.falseLabel,
+ })
+ }
+ }
+
+ return (
+
+
+
+
+
+ {assignment.user.name || assignment.user.email}
+
+
+ {ev.submittedAt
+ ? `Submitted ${formatDate(ev.submittedAt)}`
+ : 'Evaluation details'}
+
+
+
+
+ {/* Global stats */}
+
+
+
Score
+
+ {ev.globalScore != null ? `${ev.globalScore}/10` : '-'}
+
+
+
+
Decision
+
+ {ev.binaryDecision != null ? (
+ ev.binaryDecision ? (
+
+
+ Yes
+
+ ) : (
+
+
+ No
+
+ )
+ ) : (
+
-
+ )}
+
+
+
+
+ {/* Criterion Scores */}
+ {hasScores && (
+
+
+
+ Criterion Scores
+
+
+ {Object.entries(criterionScores).map(([key, value]) => {
+ const meta = criteriaMap.get(key)
+ const label = meta?.label || key
+ const type =
+ meta?.type ||
+ (typeof value === 'boolean'
+ ? 'boolean'
+ : typeof value === 'string'
+ ? 'text'
+ : 'numeric')
+
+ if (type === 'section_header') return null
+
+ if (type === 'boolean') {
+ return (
+
+ {label}
+ {value === true ? (
+
+
+ {meta?.trueLabel || 'Yes'}
+
+ ) : (
+
+
+ {meta?.falseLabel || 'No'}
+
+ )}
+
+ )
+ }
+
+ if (type === 'text') {
+ return (
+
+
{label}
+
+ {typeof value === 'string' ? value : String(value)}
+
+
+ )
+ }
+
+ // Numeric
+ return (
+
+
{label}
+
+
+
+ {typeof value === 'number' ? value : '-'}
+
+
+
+ )
+ })}
+
+
+ )}
+
+ {/* Feedback Text */}
+ {ev.feedbackText && (
+
+
+
+ Feedback
+
+
+ {ev.feedbackText}
+
+
+ )}
+
+
+
+ )
+}
+
+function ProjectDetailSkeleton() {
+ return (
+
+
+
+
+
+ {[1, 2].map((i) => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts
index 245b1f6..5c73421 100644
--- a/src/server/routers/analytics.ts
+++ b/src/server/routers/analytics.ts
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { router, observerProcedure } from '../trpc'
import { normalizeCountryToCode } from '@/lib/countries'
+import { getUserAvatarUrl } from '../utils/avatar-url'
const editionOrRoundInput = z.object({
roundId: z.string().optional(),
@@ -1237,4 +1238,136 @@ export const analyticsRouter = router({
return { roundType, stats: {} }
}
}),
+
+ /**
+ * Observer-accessible project detail: project info + assignments with evaluations + competition rounds + files.
+ * Read-only combined endpoint to avoid multiple round-trips.
+ */
+ getProjectDetail: observerProcedure
+ .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: {
+ select: {
+ id: true, fileName: true, fileType: true, mimeType: true, size: true,
+ bucket: true, objectKey: true, pageCount: true, textPreview: true,
+ detectedLang: true, langConfidence: true, analyzedAt: true,
+ requirementId: true,
+ requirement: { select: { id: true, name: true, description: true, isRequired: true } },
+ },
+ },
+ teamMembers: {
+ include: {
+ user: {
+ select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true },
+ },
+ },
+ orderBy: { joinedAt: 'asc' },
+ },
+ },
+ }),
+ 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, profileImageKey: true, profileImageProvider: true } },
+ round: { select: { id: true, name: true } },
+ evaluation: {
+ select: {
+ id: true, status: true, submittedAt: true, globalScore: true,
+ binaryDecision: true, criterionScoresJson: true, feedbackText: 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,
+ }
+ }
+
+ // Get competition rounds for file grouping
+ let competitionRounds: { id: string; name: string }[] = []
+ const competition = await ctx.prisma.competition.findFirst({
+ where: { programId: projectRaw.programId },
+ include: { rounds: { select: { id: true, name: true }, orderBy: { sortOrder: 'asc' } } },
+ })
+ if (competition) {
+ competitionRounds = competition.rounds
+ }
+
+ // Get file requirements for all rounds
+ let allRequirements: { id: string; roundId: string; name: string; description: string | null; isRequired: boolean; acceptedMimeTypes: string[]; maxSizeMB: number | null }[] = []
+ if (competitionRounds.length > 0) {
+ allRequirements = await ctx.prisma.fileRequirement.findMany({
+ where: { roundId: { in: competitionRounds.map((r) => r.id) } },
+ select: { id: true, roundId: true, name: true, description: true, isRequired: true, acceptedMimeTypes: true, maxSizeMB: true },
+ orderBy: { sortOrder: 'asc' },
+ })
+ }
+
+ // Attach avatar URLs
+ const [teamMembersWithAvatars, assignmentsWithAvatars] = 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),
+ },
+ }))
+ ),
+ ])
+
+ return {
+ project: {
+ ...projectRaw,
+ projectTags,
+ teamMembers: teamMembersWithAvatars,
+ },
+ assignments: assignmentsWithAvatars,
+ stats,
+ competitionRounds,
+ allRequirements,
+ }
+ }),
})