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,
|
UserCheck,
|
||||||
Globe,
|
Globe,
|
||||||
Layers,
|
Layers,
|
||||||
|
Trophy,
|
||||||
|
ArrowRight,
|
||||||
|
Hash,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
@@ -224,25 +227,23 @@ function ReportsOverview() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Project Reports (default: all projects, filterable by round) */}
|
{/* Project Reports Summary */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
<div>
|
||||||
<ClipboardList className="h-4 w-4 text-emerald-600" />
|
<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>
|
</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}>
|
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||||
<SelectTrigger className="w-full sm:w-[360px]">
|
<SelectTrigger className="w-[280px]">
|
||||||
<SelectValue placeholder="All projects" />
|
<SelectValue placeholder="All projects" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -259,44 +260,123 @@ function ReportsOverview() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
{projectsLoading ? (
|
{projectsLoading ? (
|
||||||
<Skeleton className="h-[400px]" />
|
<Skeleton className="h-[300px]" />
|
||||||
) : projectRankings?.length ? (
|
) : projectRankings?.length ? (
|
||||||
<div className="rounded-lg border">
|
<>
|
||||||
<Table>
|
{/* Summary stats row */}
|
||||||
<TableHeader>
|
{(() => {
|
||||||
<TableRow>
|
const evaluated = projectRankings.filter(p => p.averageScore !== null)
|
||||||
<TableHead>Project</TableHead>
|
const scores = evaluated.map(p => p.averageScore as number)
|
||||||
<TableHead className="hidden sm:table-cell">Team</TableHead>
|
const avgScore = scores.length ? scores.reduce((a, b) => a + b, 0) / scores.length : 0
|
||||||
<TableHead className="text-right">Avg</TableHead>
|
const minScore = scores.length ? Math.min(...scores) : 0
|
||||||
<TableHead className="text-right">Evals</TableHead>
|
const maxScore = scores.length ? Math.max(...scores) : 0
|
||||||
<TableHead>Status</TableHead>
|
const evalPercent = projectRankings.length ? Math.round((evaluated.length / projectRankings.length) * 100) : 0
|
||||||
</TableRow>
|
const statusCounts = projectRankings.reduce((acc, p) => {
|
||||||
</TableHeader>
|
acc[p.status] = (acc[p.status] || 0) + 1
|
||||||
<TableBody>
|
return acc
|
||||||
{projectRankings.map((p) => (
|
}, {} as Record<string, number>)
|
||||||
<TableRow key={p.id}>
|
|
||||||
<TableCell className="font-medium">
|
return (
|
||||||
<Link href={`/admin/projects/${p.id}`} className="hover:underline">
|
<>
|
||||||
{p.title}
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
</Link>
|
<div className="rounded-lg border p-3 text-center">
|
||||||
</TableCell>
|
<p className="text-xs text-muted-foreground">Total Projects</p>
|
||||||
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
<p className="text-xl font-bold tabular-nums">{projectRankings.length}</p>
|
||||||
{p.teamName || '-'}
|
</div>
|
||||||
</TableCell>
|
<div className="rounded-lg border p-3 text-center">
|
||||||
<TableCell className="text-right tabular-nums">
|
<p className="text-xs text-muted-foreground">Avg Score</p>
|
||||||
{p.averageScore === null ? '-' : p.averageScore.toFixed(2)}
|
<p className="text-xl font-bold tabular-nums">{avgScore ? avgScore.toFixed(1) : '-'}</p>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell className="text-right tabular-nums">{p.evaluationCount}</TableCell>
|
<div className="rounded-lg border p-3 text-center">
|
||||||
<TableCell>
|
<p className="text-xs text-muted-foreground">Evaluated</p>
|
||||||
<Badge variant="outline">{p.status}</Badge>
|
<p className="text-xl font-bold tabular-nums">{evalPercent}%</p>
|
||||||
</TableCell>
|
</div>
|
||||||
</TableRow>
|
<div className="rounded-lg border p-3 text-center">
|
||||||
))}
|
<p className="text-xs text-muted-foreground">Score Range</p>
|
||||||
</TableBody>
|
<p className="text-xl font-bold tabular-nums">
|
||||||
</Table>
|
{scores.length ? `${minScore.toFixed(1)}–${maxScore.toFixed(1)}` : '-'}
|
||||||
</div>
|
</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">
|
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||||
<ClipboardList className="h-10 w-10 text-muted-foreground/50" />
|
<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 }
|
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() {
|
function StageAnalytics() {
|
||||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -415,13 +519,13 @@ function StageAnalytics() {
|
|||||||
|
|
||||||
// Flatten stages from all programs with program name
|
// Flatten stages from all programs with program name
|
||||||
const rounds = programs?.flatMap(p =>
|
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(() => {
|
useEffect(() => {
|
||||||
if (rounds.length && !selectedValue) {
|
if (rounds.length && !selectedValue) {
|
||||||
setSelectedValue(rounds[0].id)
|
setSelectedValue(findDefaultRound(rounds) ?? rounds[0].id)
|
||||||
}
|
}
|
||||||
}, [rounds.length, selectedValue])
|
}, [rounds.length, selectedValue])
|
||||||
|
|
||||||
@@ -702,12 +806,12 @@ function JurorConsistencyTab() {
|
|||||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
|
|
||||||
const stages = programs?.flatMap((p) =>
|
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(() => {
|
useEffect(() => {
|
||||||
if (stages.length && !selectedValue) {
|
if (stages.length && !selectedValue) {
|
||||||
setSelectedValue(stages[0].id)
|
setSelectedValue(findDefaultRound(stages) ?? stages[0].id)
|
||||||
}
|
}
|
||||||
}, [stages.length, selectedValue])
|
}, [stages.length, selectedValue])
|
||||||
|
|
||||||
@@ -776,12 +880,12 @@ function DiversityTab() {
|
|||||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
|
|
||||||
const stages = programs?.flatMap((p) =>
|
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(() => {
|
useEffect(() => {
|
||||||
if (stages.length && !selectedValue) {
|
if (stages.length && !selectedValue) {
|
||||||
setSelectedValue(stages[0].id)
|
setSelectedValue(findDefaultRound(stages) ?? stages[0].id)
|
||||||
}
|
}
|
||||||
}, [stages.length, selectedValue])
|
}, [stages.length, selectedValue])
|
||||||
|
|
||||||
@@ -934,12 +1038,12 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeStages: true })
|
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
const pdfStages = pdfPrograms?.flatMap((p) =>
|
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(() => {
|
useEffect(() => {
|
||||||
if (pdfStages.length && !pdfStageId) {
|
if (pdfStages.length && !pdfStageId) {
|
||||||
setPdfStageId(pdfStages[0].id)
|
setPdfStageId(findDefaultRound(pdfStages) ?? pdfStages[0].id)
|
||||||
}
|
}
|
||||||
}, [pdfStages.length, pdfStageId])
|
}, [pdfStages.length, pdfStageId])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user