Add observer project detail page with files, evaluations & reviews
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m59s
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:
@@ -657,7 +657,7 @@ function CrossStageTab() {
|
|||||||
className="cursor-pointer text-sm py-1.5 px-3"
|
className="cursor-pointer text-sm py-1.5 px-3"
|
||||||
onClick={() => toggleRound(stage.id)}
|
onClick={() => toggleRound(stage.id)}
|
||||||
>
|
>
|
||||||
{stage.programName} - {stage.name}
|
{stage.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -740,7 +740,7 @@ function JurorConsistencyTab() {
|
|||||||
))}
|
))}
|
||||||
{stages.map((stage) => (
|
{stages.map((stage) => (
|
||||||
<SelectItem key={stage.id} value={stage.id}>
|
<SelectItem key={stage.id} value={stage.id}>
|
||||||
{stage.programName} - {stage.name}
|
{stage.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -814,7 +814,7 @@ function DiversityTab() {
|
|||||||
))}
|
))}
|
||||||
{stages.map((stage) => (
|
{stages.map((stage) => (
|
||||||
<SelectItem key={stage.id} value={stage.id}>
|
<SelectItem key={stage.id} value={stage.id}>
|
||||||
{stage.programName} - {stage.name}
|
{stage.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -992,7 +992,7 @@ export default function ReportsPage() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{pdfStages.map((stage) => (
|
{pdfStages.map((stage) => (
|
||||||
<SelectItem key={stage.id} value={stage.id}>
|
<SelectItem key={stage.id} value={stage.id}>
|
||||||
{stage.programName} - {stage.name}
|
{stage.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
15
src/app/(observer)/observer/projects/[projectId]/page.tsx
Normal file
15
src/app/(observer)/observer/projects/[projectId]/page.tsx
Normal 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} />
|
||||||
|
}
|
||||||
@@ -508,9 +508,9 @@ function CrossStageTab() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
pressed={selectedRoundIds.includes(stage.id)}
|
pressed={selectedRoundIds.includes(stage.id)}
|
||||||
onPressedChange={() => toggleRound(stage.id)}
|
onPressedChange={() => toggleRound(stage.id)}
|
||||||
aria-label={`${stage.programName} - ${stage.name}`}
|
aria-label={stage.name}
|
||||||
>
|
>
|
||||||
{stage.programName} - {stage.name}
|
{stage.name}
|
||||||
</Toggle>
|
</Toggle>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -644,7 +644,7 @@ export default function ObserverReportsPage() {
|
|||||||
))}
|
))}
|
||||||
{stages.map((stage) => (
|
{stages.map((stage) => (
|
||||||
<SelectItem key={stage.id} value={stage.id}>
|
<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>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const STATUS_COLORS: Record<string, string> = {
|
|||||||
export const STATUS_LABELS: Record<string, string> = {
|
export const STATUS_LABELS: Record<string, string> = {
|
||||||
SUBMITTED: 'Submitted',
|
SUBMITTED: 'Submitted',
|
||||||
ELIGIBLE: 'Eligible',
|
ELIGIBLE: 'Eligible',
|
||||||
ASSIGNED: 'Assigned',
|
ASSIGNED: 'Special Award',
|
||||||
SEMIFINALIST: 'Semi-finalist',
|
SEMIFINALIST: 'Semi-finalist',
|
||||||
FINALIST: 'Finalist',
|
FINALIST: 'Finalist',
|
||||||
REJECTED: 'Rejected',
|
REJECTED: 'Rejected',
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
|||||||
: topCountries
|
: topCountries
|
||||||
|
|
||||||
const nivoPieData = countryPieData.map((c) => ({
|
const nivoPieData = countryPieData.map((c) => ({
|
||||||
id: c.country,
|
id: c.country === 'Others' ? 'Others' : c.country.toUpperCase(),
|
||||||
label: getCountryName(c.country),
|
label: getCountryName(c.country),
|
||||||
value: c.count,
|
value: c.count,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ export function ProjectRankingsChart({
|
|||||||
data,
|
data,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
}: ProjectRankingsProps) {
|
}: ProjectRankingsProps) {
|
||||||
if (!data?.length) return null
|
const scoredData = (data ?? []).filter(
|
||||||
|
|
||||||
const scoredData = data.filter(
|
|
||||||
(d): d is ProjectRankingData & { averageScore: number } =>
|
(d): d is ProjectRankingData & { averageScore: number } =>
|
||||||
d.averageScore !== null,
|
d.averageScore !== null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!scoredData.length) return null
|
||||||
|
|
||||||
const averageScore =
|
const averageScore =
|
||||||
scoredData.length > 0
|
scoredData.length > 0
|
||||||
? scoredData.reduce((sum, d) => sum + d.averageScore, 0) /
|
? scoredData.reduce((sum, d) => sum + d.averageScore, 0) /
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -150,26 +151,6 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Round Filter */}
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
<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>
|
<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>
|
<SelectItem value="all">All Rounds</SelectItem>
|
||||||
{rounds.map((round) => (
|
{rounds.map((round) => (
|
||||||
<SelectItem key={round.id} value={round.id}>
|
<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>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -364,7 +345,6 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
Title<SortIcon column="title" />
|
Title<SortIcon column="title" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>Team</TableHead>
|
|
||||||
<TableHead>Round</TableHead>
|
<TableHead>Round</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">
|
||||||
@@ -381,11 +361,12 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{projectsData.projects.map((project) => (
|
{projectsData.projects.map((project) => (
|
||||||
<TableRow key={project.id}>
|
<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-[250px] truncate">
|
<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}
|
{project.title}
|
||||||
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[150px] truncate">{project.teamName || '-'}</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline" className="text-xs whitespace-nowrap">
|
<Badge variant="outline" className="text-xs whitespace-nowrap">
|
||||||
{project.roundName}
|
{project.roundName}
|
||||||
@@ -411,15 +392,13 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
{/* Mobile Cards */}
|
{/* Mobile Cards */}
|
||||||
<div className="space-y-3 md:hidden">
|
<div className="space-y-3 md:hidden">
|
||||||
{projectsData.projects.map((project) => (
|
{projectsData.projects.map((project) => (
|
||||||
<Card key={project.id}>
|
<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">
|
<CardContent className="pt-4 space-y-2">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p className="font-medium text-sm leading-tight">{project.title}</p>
|
<p className="font-medium text-sm leading-tight">{project.title}</p>
|
||||||
<StatusBadge status={project.status} />
|
<StatusBadge status={project.status} />
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{project.roundName}
|
{project.roundName}
|
||||||
@@ -431,6 +410,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
1051
src/components/observer/observer-project-detail.tsx
Normal file
1051
src/components/observer/observer-project-detail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { router, observerProcedure } from '../trpc'
|
import { router, observerProcedure } from '../trpc'
|
||||||
import { normalizeCountryToCode } from '@/lib/countries'
|
import { normalizeCountryToCode } from '@/lib/countries'
|
||||||
|
import { getUserAvatarUrl } from '../utils/avatar-url'
|
||||||
|
|
||||||
const editionOrRoundInput = z.object({
|
const editionOrRoundInput = z.object({
|
||||||
roundId: z.string().optional(),
|
roundId: z.string().optional(),
|
||||||
@@ -1237,4 +1238,136 @@ export const analyticsRouter = router({
|
|||||||
return { roundType, stats: {} }
|
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,
|
||||||
|
}
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user