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
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:
@@ -42,6 +42,9 @@ import {
|
||||
UserCheck,
|
||||
Globe,
|
||||
Layers,
|
||||
Trophy,
|
||||
ArrowRight,
|
||||
Hash,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import {
|
||||
@@ -224,25 +227,23 @@ function ReportsOverview() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Project Reports (default: all projects, filterable by round) */}
|
||||
{/* Project Reports Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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" />
|
||||
<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" />
|
||||
</div>
|
||||
Project Reports
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Summary dashboard — optionally filter to a specific round
|
||||
</CardDescription>
|
||||
</div>
|
||||
Project Reports
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Project-wide reporting across all projects — 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>
|
||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="w-full sm:w-[360px]">
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="All projects" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -259,44 +260,123 @@ function ReportsOverview() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{projectsLoading ? (
|
||||
<Skeleton className="h-[400px]" />
|
||||
<Skeleton className="h-[300px]" />
|
||||
) : projectRankings?.length ? (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Team</TableHead>
|
||||
<TableHead className="text-right">Avg</TableHead>
|
||||
<TableHead className="text-right">Evals</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projectRankings.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/admin/projects/${p.id}`} className="hover:underline">
|
||||
{p.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
||||
{p.teamName || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{p.averageScore === null ? '-' : p.averageScore.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{p.evaluationCount}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{p.status}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<>
|
||||
{/* 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>
|
||||
<TableHead className="text-right">Evals</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
||||
{p.teamName || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{p.averageScore === null ? '-' : p.averageScore.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{p.evaluationCount}</TableCell>
|
||||
<TableCell>
|
||||
<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])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user