Add observer project detail page with files, evaluations & reviews
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m59s

New page at /observer/projects/[projectId] showing project info,
documents grouped by round requirements, and jury evaluations with
click-through to full review details. Dashboard table rows now link
to project detail. Also cleans up redundant programName prefixes
and fixes chart edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-20 18:39:53 +01:00
parent f1062f4805
commit 03c59c188e
9 changed files with 1236 additions and 57 deletions

View File

@@ -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}
</Badge>
)
})}
@@ -740,7 +740,7 @@ function JurorConsistencyTab() {
))}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
{stage.name}
</SelectItem>
))}
</SelectContent>
@@ -814,7 +814,7 @@ function DiversityTab() {
))}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
{stage.name}
</SelectItem>
))}
</SelectContent>
@@ -992,7 +992,7 @@ export default function ReportsPage() {
<SelectContent>
{pdfStages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
{stage.name}
</SelectItem>
))}
</SelectContent>

View File

@@ -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 <ObserverProjectDetail projectId={projectId} />
}

View File

@@ -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}
</Toggle>
))}
</div>
@@ -644,7 +644,7 @@ export default function ObserverReportsPage() {
))}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}{stage.roundType ? ` (${ROUND_TYPE_LABELS[stage.roundType] || stage.roundType})` : ''}
{stage.name}{stage.roundType ? ` (${ROUND_TYPE_LABELS[stage.roundType] || stage.roundType})` : ''}
</SelectItem>
))}
</SelectContent>

View File

@@ -36,7 +36,7 @@ export const STATUS_COLORS: Record<string, string> = {
export const STATUS_LABELS: Record<string, string> = {
SUBMITTED: 'Submitted',
ELIGIBLE: 'Eligible',
ASSIGNED: 'Assigned',
ASSIGNED: 'Special Award',
SEMIFINALIST: 'Semi-finalist',
FINALIST: 'Finalist',
REJECTED: 'Rejected',

View File

@@ -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,
}))

View File

@@ -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) /

View File

@@ -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 }) {
</p>
</div>
{/* Observer Notice */}
<div className="rounded-lg border border-blue-200 bg-blue-50/50 px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-blue-100 p-2.5">
<Eye className="h-4 w-4 text-blue-600" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="font-semibold text-blue-900">Observer Mode</p>
<Badge variant="outline" className="border-blue-300 text-blue-700 text-xs">
Read-Only
</Badge>
</div>
<p className="text-sm text-blue-700">
You have read-only access to view platform statistics and reports.
</p>
</div>
</div>
</div>
{/* Round Filter */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<label className="text-sm font-medium">Filter by Round:</label>
@@ -181,7 +162,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<SelectItem value="all">All Rounds</SelectItem>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}{round.roundType ? ` (${round.roundType.replace(/_/g, ' ')})` : ''}
{round.name}{round.roundType ? ` (${round.roundType.replace(/_/g, ' ')})` : ''}
</SelectItem>
))}
</SelectContent>
@@ -364,7 +345,6 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
Title<SortIcon column="title" />
</button>
</TableHead>
<TableHead>Team</TableHead>
<TableHead>Round</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">
@@ -381,11 +361,12 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</TableHeader>
<TableBody>
{projectsData.projects.map((project) => (
<TableRow key={project.id}>
<TableCell className="font-medium max-w-[250px] truncate">
{project.title}
<TableRow key={project.id} className="cursor-pointer hover:bg-muted/50" onClick={() => window.location.href = `/observer/projects/${project.id}`}>
<TableCell className="font-medium max-w-[300px] truncate">
<Link href={`/observer/projects/${project.id}` as Route} className="hover:underline" onClick={(e) => e.stopPropagation()}>
{project.title}
</Link>
</TableCell>
<TableCell className="max-w-[150px] truncate">{project.teamName || '-'}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs whitespace-nowrap">
{project.roundName}
@@ -411,26 +392,25 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
{/* Mobile Cards */}
<div className="space-y-3 md:hidden">
{projectsData.projects.map((project) => (
<Card key={project.id}>
<CardContent className="pt-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-sm leading-tight">{project.title}</p>
<StatusBadge status={project.status} />
</div>
{project.teamName && (
<p className="text-xs text-muted-foreground">{project.teamName}</p>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<Badge variant="outline" className="text-xs">
{project.roundName}
</Badge>
<div className="flex gap-3">
<span>Score: {project.averageScore !== null ? project.averageScore.toFixed(2) : '-'}</span>
<span>{project.evaluationCount} eval{project.evaluationCount !== 1 ? 's' : ''}</span>
<Link key={project.id} href={`/observer/projects/${project.id}` as Route}>
<Card className="transition-colors hover:bg-muted/50">
<CardContent className="pt-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-sm leading-tight">{project.title}</p>
<StatusBadge status={project.status} />
</div>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<Badge variant="outline" className="text-xs">
{project.roundName}
</Badge>
<div className="flex gap-3">
<span>Score: {project.averageScore !== null ? project.averageScore.toFixed(2) : '-'}</span>
<span>{project.evaluationCount} eval{project.evaluationCount !== 1 ? 's' : ''}</span>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -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,
}
}),
})