Improve reports page: active round defaults, compact project summary, status labels
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m34s

- Default round selectors to ROUND_ACTIVE instead of first round (StageAnalytics, JurorConsistency, Diversity, PDF export)
- Reimagine Project Reports as compact dashboard: summary stats, status chips, top-10 table
- Map ELIGIBLE status to "Special Award" display label

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 20:18:14 +01:00
parent 61c4d0eb75
commit 5ece50268b

View File

@@ -42,6 +42,9 @@ import {
UserCheck,
Globe,
Layers,
Trophy,
ArrowRight,
Hash,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import {
@@ -224,9 +227,11 @@ function ReportsOverview() {
</Card>
)}
{/* Project Reports (default: all projects, filterable by round) */}
{/* Project Reports Summary */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<ClipboardList className="h-4 w-4 text-emerald-600" />
@@ -234,15 +239,11 @@ function ReportsOverview() {
Project Reports
</CardTitle>
<CardDescription>
Project-wide reporting across all projects optionally filter to a specific round
Summary dashboard optionally filter to a specific round
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Scope selector */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<span className="text-sm font-medium">Scope:</span>
</div>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-full sm:w-[360px]">
<SelectTrigger className="w-[280px]">
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent>
@@ -259,14 +260,72 @@ function ReportsOverview() {
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="space-y-5">
{projectsLoading ? (
<Skeleton className="h-[400px]" />
<Skeleton className="h-[300px]" />
) : projectRankings?.length ? (
<>
{/* Summary stats row */}
{(() => {
const evaluated = projectRankings.filter(p => p.averageScore !== null)
const scores = evaluated.map(p => p.averageScore as number)
const avgScore = scores.length ? scores.reduce((a, b) => a + b, 0) / scores.length : 0
const minScore = scores.length ? Math.min(...scores) : 0
const maxScore = scores.length ? Math.max(...scores) : 0
const evalPercent = projectRankings.length ? Math.round((evaluated.length / projectRankings.length) * 100) : 0
const statusCounts = projectRankings.reduce((acc, p) => {
acc[p.status] = (acc[p.status] || 0) + 1
return acc
}, {} as Record<string, number>)
return (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Total Projects</p>
<p className="text-xl font-bold tabular-nums">{projectRankings.length}</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Avg Score</p>
<p className="text-xl font-bold tabular-nums">{avgScore ? avgScore.toFixed(1) : '-'}</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Evaluated</p>
<p className="text-xl font-bold tabular-nums">{evalPercent}%</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Score Range</p>
<p className="text-xl font-bold tabular-nums">
{scores.length ? `${minScore.toFixed(1)}${maxScore.toFixed(1)}` : '-'}
</p>
</div>
</div>
{/* Status breakdown chips */}
{Object.keys(statusCounts).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(statusCounts)
.sort(([, a], [, b]) => b - a)
.map(([status, count]) => (
<Badge key={status} variant="outline" className="gap-1">
<Hash className="h-3 w-3" />
{formatStatusLabel(status)} <span className="font-bold tabular-nums">{count}</span>
</Badge>
))}
</div>
)}
{/* Top 10 ranked table */}
<div>
<p className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5" /> Top 10 by Average Score
</p>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">#</TableHead>
<TableHead>Project</TableHead>
<TableHead className="hidden sm:table-cell">Team</TableHead>
<TableHead className="text-right">Avg</TableHead>
@@ -275,8 +334,9 @@ function ReportsOverview() {
</TableRow>
</TableHeader>
<TableBody>
{projectRankings.map((p) => (
{projectRankings.slice(0, 10).map((p, idx) => (
<TableRow key={p.id}>
<TableCell className="text-muted-foreground tabular-nums">{idx + 1}</TableCell>
<TableCell className="font-medium">
<Link href={`/admin/projects/${p.id}`} className="hover:underline">
{p.title}
@@ -290,13 +350,33 @@ function ReportsOverview() {
</TableCell>
<TableCell className="text-right tabular-nums">{p.evaluationCount}</TableCell>
<TableCell>
<Badge variant="outline">{p.status}</Badge>
<Badge variant="outline">{formatStatusLabel(p.status)}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* Link to full analytics */}
{projectRankings.length > 10 && (
<div className="flex justify-end">
<Button variant="ghost" size="sm" className="gap-1 text-muted-foreground"
onClick={() => {
const tabs = document.querySelectorAll('[role="tab"]')
const analyticsTab = Array.from(tabs).find(t => t.textContent?.includes('Analytics')) as HTMLElement
analyticsTab?.click()
}}
>
View full rankings in Analytics <ArrowRight className="h-3.5 w-3.5" />
</Button>
</div>
)}
</>
)
})()}
</>
) : (
<div className="flex flex-col items-center justify-center py-10 text-center">
<ClipboardList className="h-10 w-10 text-muted-foreground/50" />
@@ -408,6 +488,30 @@ function parseSelection(value: string | null): { roundId?: string; programId?: s
return { roundId: value }
}
// Map raw DB status to display-friendly labels
function formatStatusLabel(status: string): string {
const labels: Record<string, string> = {
ELIGIBLE: 'Special Award',
ASSIGNED: 'Assigned',
PENDING: 'Pending',
IN_PROGRESS: 'In Progress',
PASSED: 'Passed',
REJECTED: 'Rejected',
COMPLETED: 'Completed',
WITHDRAWN: 'Withdrawn',
}
return labels[status] ?? status
}
// Find the best default round: active > last closed > first
function findDefaultRound(rounds: Array<{ id: string; status?: string }>): string | undefined {
const active = rounds.find(r => r.status === 'ROUND_ACTIVE')
if (active) return active.id
const closed = [...rounds].reverse().find(r => r.status === 'ROUND_CLOSED')
if (closed) return closed.id
return rounds[0]?.id
}
function StageAnalytics() {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
@@ -415,13 +519,13 @@ function StageAnalytics() {
// Flatten stages from all programs with program name
const rounds = programs?.flatMap(p =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
) || []
// Set default selected stage
// Set default selected stage — prefer active round
useEffect(() => {
if (rounds.length && !selectedValue) {
setSelectedValue(rounds[0].id)
setSelectedValue(findDefaultRound(rounds) ?? rounds[0].id)
}
}, [rounds.length, selectedValue])
@@ -702,12 +806,12 @@ function JurorConsistencyTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programId: p.id, programName: `${p.year} Edition` }))
) || []
useEffect(() => {
if (stages.length && !selectedValue) {
setSelectedValue(stages[0].id)
setSelectedValue(findDefaultRound(stages) ?? stages[0].id)
}
}, [stages.length, selectedValue])
@@ -776,12 +880,12 @@ function DiversityTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programId: p.id, programName: `${p.year} Edition` }))
) || []
useEffect(() => {
if (stages.length && !selectedValue) {
setSelectedValue(stages[0].id)
setSelectedValue(findDefaultRound(stages) ?? stages[0].id)
}
}, [stages.length, selectedValue])
@@ -934,12 +1038,12 @@ export default function ReportsPage() {
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeStages: true })
const pdfStages = pdfPrograms?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programName: `${p.year} Edition` }))
) || []
useEffect(() => {
if (pdfStages.length && !pdfStageId) {
setPdfStageId(pdfStages[0].id)
setPdfStageId(findDefaultRound(pdfStages) ?? pdfStages[0].id)
}
}, [pdfStages.length, pdfStageId])