Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s

Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 23:04:15 +01:00
parent 9ab4717f96
commit 6ca39c976b
349 changed files with 69938 additions and 28767 deletions

View File

@@ -77,7 +77,7 @@ function ReportsOverview() {
}
const scopeInput = parseSelection(selectedValue)
const hasScope = !!scopeInput.stageId || !!scopeInput.programId
const hasScope = !!scopeInput.roundId || !!scopeInput.programId
const { data: projectRankings, isLoading: projectsLoading } =
trpc.analytics.getProjectRankings.useQuery(
@@ -107,7 +107,7 @@ function ReportsOverview() {
const totalPrograms = dashStats?.programCount ?? programs?.length ?? 0
const totalProjects = dashStats?.projectCount ?? 0
const activeRounds = dashStats?.activeStageCount ?? rounds.filter((r: { status: string }) => r.status === 'STAGE_ACTIVE').length
const activeRounds = dashStats?.activeRoundCount ?? rounds.filter((r: { status: string }) => r.status === 'ROUND_ACTIVE').length
const jurorCount = dashStats?.jurorCount ?? 0
const submittedEvaluations = dashStats?.submittedEvaluations ?? 0
const totalEvaluations = dashStats?.totalEvaluations ?? 0
@@ -400,11 +400,11 @@ function ReportsOverview() {
)
}
// Parse selection value: "all:programId" for edition-wide, or stageId
function parseSelection(value: string | null): { stageId?: string; programId?: string } {
// Parse selection value: "all:programId" for edition-wide, or roundId
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
if (!value) return {}
if (value.startsWith('all:')) return { programId: value.slice(4) }
return { stageId: value }
return { roundId: value }
}
function StageAnalytics() {
@@ -423,7 +423,7 @@ function StageAnalytics() {
}
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(
@@ -464,11 +464,11 @@ function StageAnalytics() {
const selectedRound = rounds.find((r) => r.id === selectedValue)
const geoInput = queryInput.programId
? { programId: queryInput.programId }
: { programId: selectedRound?.programId || '', stageId: queryInput.stageId }
: { programId: selectedRound?.programId || '', roundId: queryInput.roundId }
const { data: geoData, isLoading: geoLoading } =
trpc.analytics.getGeographicDistribution.useQuery(
geoInput,
{ enabled: hasSelection && !!(geoInput.programId || geoInput.stageId) }
{ enabled: hasSelection && !!(geoInput.programId || geoInput.roundId) }
)
if (roundsLoading) {
@@ -613,19 +613,19 @@ function CrossStageTab() {
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
) || []
const [selectedStageIds, setSelectedStageIds] = useState<string[]>([])
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
const { data: comparison, isLoading: comparisonLoading } =
trpc.analytics.getCrossStageComparison.useQuery(
{ stageIds: selectedStageIds },
{ enabled: selectedStageIds.length >= 2 }
trpc.analytics.getCrossRoundComparison.useQuery(
{ roundIds: selectedRoundIds },
{ enabled: selectedRoundIds.length >= 2 }
)
const toggleStage = (stageId: string) => {
setSelectedStageIds((prev) =>
prev.includes(stageId)
? prev.filter((id) => id !== stageId)
: [...prev, stageId]
const toggleRound = (roundId: string) => {
setSelectedRoundIds((prev) =>
prev.includes(roundId)
? prev.filter((id) => id !== roundId)
: [...prev, roundId]
)
}
@@ -646,20 +646,20 @@ function CrossStageTab() {
<CardContent>
<div className="flex flex-wrap gap-2">
{stages.map((stage) => {
const isSelected = selectedStageIds.includes(stage.id)
const isSelected = selectedRoundIds.includes(stage.id)
return (
<Badge
key={stage.id}
variant={isSelected ? 'default' : 'outline'}
className="cursor-pointer text-sm py-1.5 px-3"
onClick={() => toggleStage(stage.id)}
onClick={() => toggleRound(stage.id)}
>
{stage.programName} - {stage.name}
</Badge>
)
})}
</div>
{selectedStageIds.length < 2 && (
{selectedRoundIds.length < 2 && (
<p className="text-sm text-muted-foreground mt-3">
Select at least 2 stages to enable comparison
</p>
@@ -668,7 +668,7 @@ function CrossStageTab() {
</Card>
{/* Comparison charts */}
{comparisonLoading && selectedStageIds.length >= 2 && (
{comparisonLoading && selectedRoundIds.length >= 2 && (
<div className="space-y-6">
<Skeleton className="h-[350px]" />
<div className="grid gap-6 lg:grid-cols-2">
@@ -680,8 +680,8 @@ function CrossStageTab() {
{comparison && (
<CrossStageComparisonChart data={comparison as Array<{
stageId: string
stageName: string
roundId: string
roundName: string
projectCount: number
evaluationCount: number
completionRate: number
@@ -707,7 +707,7 @@ function JurorConsistencyTab() {
}
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: consistency, isLoading: consistencyLoading } =
trpc.analytics.getJurorConsistency.useQuery(
@@ -779,7 +779,7 @@ function DiversityTab() {
}
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: diversity, isLoading: diversityLoading } =
trpc.analytics.getDiversityMetrics.useQuery(
@@ -882,7 +882,7 @@ export default function ReportsPage() {
<TabsTrigger value="pipeline" className="gap-2" asChild>
<Link href={"/admin/reports/stages" as Route}>
<Layers className="h-4 w-4" />
By Pipeline
By Round
</Link>
</TabsTrigger>
</TabsList>
@@ -901,7 +901,7 @@ export default function ReportsPage() {
</Select>
{pdfStageId && (
<ExportPdfButton
stageId={pdfStageId}
roundId={pdfStageId}
roundName={selectedPdfStage?.name}
programName={selectedPdfStage?.programName}
/>

View File

@@ -1,671 +0,0 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { useEdition } from '@/contexts/edition-context'
import Link from 'next/link'
import type { Route } from 'next'
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
ArrowLeft,
BarChart3,
Layers,
Users,
CheckCircle2,
Target,
Trophy,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const stateColors: Record<string, string> = {
PENDING: 'bg-gray-100 text-gray-700',
IN_PROGRESS: 'bg-blue-100 text-blue-700',
PASSED: 'bg-emerald-100 text-emerald-700',
REJECTED: 'bg-red-100 text-red-700',
COMPLETED: 'bg-emerald-100 text-emerald-700',
ELIMINATED: 'bg-red-100 text-red-700',
WAITING: 'bg-amber-100 text-amber-700',
}
const awardStatusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700',
NOMINATIONS_OPEN: 'bg-blue-100 text-blue-700',
VOTING_OPEN: 'bg-amber-100 text-amber-700',
CLOSED: 'bg-emerald-100 text-emerald-700',
ARCHIVED: 'bg-gray-100 text-gray-500',
}
// ─── Stages Tab Content ────────────────────────────────────────────────────
function StagesTabContent({
activePipelineId,
}: {
activePipelineId: string
}) {
const [selectedStageId, setSelectedStageId] = useState<string>('')
const { data: overview, isLoading: overviewLoading } =
trpc.analytics.getStageCompletionOverview.useQuery(
{ pipelineId: activePipelineId },
{ enabled: !!activePipelineId }
)
const { data: scoreDistribution, isLoading: scoresLoading } =
trpc.analytics.getStageScoreDistribution.useQuery(
{ stageId: selectedStageId },
{ enabled: !!selectedStageId }
)
if (overviewLoading) {
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!overview) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<BarChart3 className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No pipeline data</p>
<p className="text-sm text-muted-foreground mt-1">
Select a pipeline to view analytics.
</p>
</CardContent>
</Card>
)
}
return (
<>
{/* Summary cards */}
<div className="grid gap-4 sm:grid-cols-4">
<Card>
<CardContent className="py-5 text-center">
<Layers className="h-5 w-5 text-muted-foreground mx-auto mb-1" />
<p className="text-2xl font-bold">{overview.summary.totalStages}</p>
<p className="text-xs text-muted-foreground">Total Stages</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-5 text-center">
<Target className="h-5 w-5 text-muted-foreground mx-auto mb-1" />
<p className="text-2xl font-bold">{overview.summary.totalProjects}</p>
<p className="text-xs text-muted-foreground">Total Projects</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-5 text-center">
<Users className="h-5 w-5 text-muted-foreground mx-auto mb-1" />
<p className="text-2xl font-bold">{overview.summary.totalAssignments}</p>
<p className="text-xs text-muted-foreground">Total Assignments</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-5 text-center">
<CheckCircle2 className="h-5 w-5 text-emerald-600 mx-auto mb-1" />
<p className="text-2xl font-bold">{overview.summary.totalCompleted}</p>
<p className="text-xs text-muted-foreground">Evaluations Completed</p>
</CardContent>
</Card>
</div>
{/* Stage overview table */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Stage Completion Overview</CardTitle>
<CardDescription>
Click a stage to see detailed score distribution
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Stage</TableHead>
<TableHead>Track</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-center">Projects</TableHead>
<TableHead className="text-center">Assignments</TableHead>
<TableHead className="text-center">Completed</TableHead>
<TableHead className="text-center">Jurors</TableHead>
<TableHead>Progress</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{overview.stages.map((stage) => (
<TableRow
key={stage.stageId}
className={cn(
'cursor-pointer transition-colors',
selectedStageId === stage.stageId && 'bg-brand-blue/5 dark:bg-brand-teal/5'
)}
onClick={() => setSelectedStageId(stage.stageId)}
>
<TableCell className="font-medium">{stage.stageName}</TableCell>
<TableCell className="text-muted-foreground text-sm">{stage.trackName}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs capitalize">
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
</Badge>
</TableCell>
<TableCell className="text-center tabular-nums">{stage.totalProjects}</TableCell>
<TableCell className="text-center tabular-nums">{stage.totalAssignments}</TableCell>
<TableCell className="text-center tabular-nums">{stage.completedEvaluations}</TableCell>
<TableCell className="text-center tabular-nums">{stage.jurorCount}</TableCell>
<TableCell>
<div className="flex items-center gap-2 min-w-[120px]">
<Progress value={stage.completionRate} className="h-2 flex-1" />
<span className="text-xs tabular-nums font-medium w-8 text-right">
{stage.completionRate}%
</span>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* State breakdown per stage */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Project State Breakdown</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{overview.stages.map((stage) => (
<div key={stage.stageId} className="space-y-2">
<p className="text-sm font-medium">{stage.stageName}</p>
<div className="flex flex-wrap gap-2">
{stage.stateBreakdown.map((sb) => (
<Badge
key={sb.state}
variant="outline"
className={cn('text-xs', stateColors[sb.state])}
>
{sb.state}: {sb.count}
</Badge>
))}
{stage.stateBreakdown.length === 0 && (
<span className="text-xs text-muted-foreground">No projects</span>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Score distribution for selected stage */}
{selectedStageId && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
Score Distribution
{overview.stages.find((s) => s.stageId === selectedStageId) && (
<span className="text-sm font-normal text-muted-foreground ml-2">
{overview.stages.find((s) => s.stageId === selectedStageId)?.stageName}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
{scoresLoading ? (
<Skeleton className="h-48 w-full" />
) : scoreDistribution ? (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="text-center">
<p className="text-2xl font-bold tabular-nums">
{scoreDistribution.totalEvaluations}
</p>
<p className="text-xs text-muted-foreground">Evaluations</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold tabular-nums">
{scoreDistribution.averageGlobalScore.toFixed(1)}
</p>
<p className="text-xs text-muted-foreground">Avg Score</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold tabular-nums">
{scoreDistribution.criterionDistributions.length}
</p>
<p className="text-xs text-muted-foreground">Criteria</p>
</div>
</div>
{/* Score histogram (text-based) */}
<div className="space-y-2">
<p className="text-sm font-medium">Global Score Distribution</p>
{scoreDistribution.globalDistribution.map((bucket) => {
const maxCount = Math.max(
...scoreDistribution.globalDistribution.map((b) => b.count),
1
)
const width = (bucket.count / maxCount) * 100
return (
<div key={bucket.score} className="flex items-center gap-2">
<span className="text-xs tabular-nums w-4 text-right">{bucket.score}</span>
<div className="flex-1 h-5 bg-muted/30 rounded overflow-hidden">
<div
className="h-full bg-brand-blue/60 dark:bg-brand-teal/60 rounded transition-all"
style={{ width: `${width}%` }}
/>
</div>
<span className="text-xs tabular-nums w-6 text-right">{bucket.count}</span>
</div>
)
})}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No score data available for this stage.</p>
)}
</CardContent>
</Card>
)}
</>
)
}
// ─── Awards Tab Content ────────────────────────────────────────────────────
function AwardsTabContent({
activePipelineId,
}: {
activePipelineId: string
}) {
const [selectedAwardStageId, setSelectedAwardStageId] = useState<string>('')
const { data: awards, isLoading: awardsLoading } =
trpc.analytics.getAwardSummary.useQuery(
{ pipelineId: activePipelineId },
{ enabled: !!activePipelineId }
)
const { data: voteDistribution, isLoading: votesLoading } =
trpc.analytics.getAwardVoteDistribution.useQuery(
{ stageId: selectedAwardStageId },
{ enabled: !!selectedAwardStageId }
)
if (awardsLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-24" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!awards || awards.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No award tracks</p>
<p className="text-sm text-muted-foreground mt-1">
This pipeline has no award tracks configured.
</p>
</CardContent>
</Card>
)
}
return (
<>
{/* Awards summary table */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Trophy className="h-5 w-5" />
Award Tracks
</CardTitle>
<CardDescription>
Click an award&apos;s stage to see vote/score distribution
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Award</TableHead>
<TableHead>Status</TableHead>
<TableHead>Scoring</TableHead>
<TableHead>Routing</TableHead>
<TableHead className="text-center">Projects</TableHead>
<TableHead className="text-center">Completion</TableHead>
<TableHead>Winner</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{awards.map((award) => (
<TableRow key={award.trackId}>
<TableCell className="font-medium">{award.awardName}</TableCell>
<TableCell>
{award.awardStatus ? (
<Badge
variant="outline"
className={cn('text-xs', awardStatusColors[award.awardStatus])}
>
{award.awardStatus.replace(/_/g, ' ')}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{award.scoringMode ? (
<Badge variant="outline" className="text-xs capitalize">
{award.scoringMode.toLowerCase().replace(/_/g, ' ')}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{award.routingMode ? (
<Badge variant="outline" className="text-xs">
{award.routingMode}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-center tabular-nums">{award.projectCount}</TableCell>
<TableCell>
<div className="flex items-center gap-2 min-w-[100px]">
<Progress value={award.completionRate} className="h-2 flex-1" />
<span className="text-xs tabular-nums font-medium w-8 text-right">
{award.completionRate}%
</span>
</div>
</TableCell>
<TableCell>
{award.winner ? (
<div className="flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5 text-amber-500 shrink-0" />
<span className="text-sm font-medium truncate max-w-[150px]">
{award.winner.title}
</span>
{award.winner.overridden && (
<Badge variant="outline" className="text-[10px] px-1 py-0">
overridden
</Badge>
)}
</div>
) : (
<span className="text-xs text-muted-foreground">Not finalized</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Award stages clickable list */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Award Stage Details</CardTitle>
<CardDescription>
Select a stage to view vote and score distribution
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{awards.map((award) =>
award.stages.map((stage) => (
<button
key={stage.id}
onClick={() => setSelectedAwardStageId(stage.id)}
className={cn(
'w-full text-left rounded-lg border p-3 transition-colors',
selectedAwardStageId === stage.id
? 'border-brand-blue bg-brand-blue/5 dark:border-brand-teal dark:bg-brand-teal/5'
: 'hover:bg-muted/50'
)}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{award.awardName} {stage.name}</p>
<p className="text-xs text-muted-foreground mt-0.5">
Type: {stage.stageType.toLowerCase().replace(/_/g, ' ')} | Status: {stage.status}
</p>
</div>
<Badge variant="outline" className="text-xs capitalize">
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
</Badge>
</div>
</button>
))
)}
</div>
</CardContent>
</Card>
{/* Vote/score distribution for selected award stage */}
{selectedAwardStageId && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
Vote / Score Distribution
</CardTitle>
</CardHeader>
<CardContent>
{votesLoading ? (
<Skeleton className="h-48 w-full" />
) : voteDistribution && voteDistribution.projects.length > 0 ? (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="text-center">
<p className="text-2xl font-bold tabular-nums">
{voteDistribution.totalEvaluations}
</p>
<p className="text-xs text-muted-foreground">Evaluations</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold tabular-nums">
{voteDistribution.totalVotes}
</p>
<p className="text-xs text-muted-foreground">Award Votes</p>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead className="text-center">Evals</TableHead>
<TableHead className="text-center">Votes</TableHead>
<TableHead className="text-center">Avg Score</TableHead>
<TableHead className="text-center">Min</TableHead>
<TableHead className="text-center">Max</TableHead>
<TableHead className="text-center">Avg Rank</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{voteDistribution.projects.map((project) => (
<TableRow key={project.projectId}>
<TableCell className="font-medium truncate max-w-[200px]">
{project.title}
</TableCell>
<TableCell className="text-sm text-muted-foreground truncate max-w-[120px]">
{project.teamName || '—'}
</TableCell>
<TableCell className="text-center tabular-nums">
{project.evaluationCount}
</TableCell>
<TableCell className="text-center tabular-nums">
{project.voteCount}
</TableCell>
<TableCell className="text-center tabular-nums font-medium">
{project.avgScore !== null ? project.avgScore.toFixed(1) : '—'}
</TableCell>
<TableCell className="text-center tabular-nums">
{project.minScore !== null ? project.minScore.toFixed(1) : '—'}
</TableCell>
<TableCell className="text-center tabular-nums">
{project.maxScore !== null ? project.maxScore.toFixed(1) : '—'}
</TableCell>
<TableCell className="text-center tabular-nums">
{project.avgRank !== null ? project.avgRank.toFixed(1) : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<p className="text-sm text-muted-foreground">No vote/score data available for this stage.</p>
)}
</CardContent>
</Card>
)}
</>
)
}
// ─── Main Page ─────────────────────────────────────────────────────────────
export default function StageAnalyticsReportsPage() {
const { currentEdition } = useEdition()
const programId = currentEdition?.id ?? ''
const [selectedPipelineId, setSelectedPipelineId] = useState<string>('')
// Fetch pipelines
const { data: pipelines, isLoading: pipelinesLoading } =
trpc.pipeline.list.useQuery(
{ programId },
{ enabled: !!programId }
)
// Auto-select first pipeline
const activePipelineId = selectedPipelineId || (pipelines?.[0]?.id ?? '')
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={"/admin/reports" as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Layers className="h-6 w-6" />
Pipeline Analytics
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Stage-level reporting for the pipeline system
</p>
</div>
</div>
{/* Pipeline selector */}
{pipelines && pipelines.length > 0 && (
<Select
value={activePipelineId}
onValueChange={(v) => {
setSelectedPipelineId(v)
}}
>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder="Select pipeline" />
</SelectTrigger>
<SelectContent>
{pipelines.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{pipelinesLoading ? (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
<Skeleton className="h-64 w-full" />
</div>
) : !activePipelineId ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<BarChart3 className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No pipeline data</p>
<p className="text-sm text-muted-foreground mt-1">
Select a pipeline to view analytics.
</p>
</CardContent>
</Card>
) : (
<Tabs defaultValue="stages" className="space-y-6">
<TabsList>
<TabsTrigger value="stages" className="gap-2">
<Layers className="h-4 w-4" />
Stages
</TabsTrigger>
<TabsTrigger value="awards" className="gap-2">
<Trophy className="h-4 w-4" />
Awards
</TabsTrigger>
</TabsList>
<TabsContent value="stages" className="space-y-6">
<StagesTabContent activePipelineId={activePipelineId} />
</TabsContent>
<TabsContent value="awards" className="space-y-6">
<AwardsTabContent activePipelineId={activePipelineId} />
</TabsContent>
</Tabs>
)}
</div>
)
}