Round system redesign: Phases 1-7 complete
Full pipeline/track/stage architecture replacing the legacy round system. Schema: 11 new models (Pipeline, Track, Stage, StageTransition, ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor, OverrideAction, AudienceVoter) + 8 new enums. Backend: 9 new routers (pipeline, stage, routing, stageFiltering, stageAssignment, cohort, live, decision, award) + 6 new services (stage-engine, routing-engine, stage-filtering, stage-assignment, stage-notifications, live-control). Frontend: Pipeline wizard (17 components), jury stage pages (7), applicant pipeline pages (3), public stage pages (2), admin pipeline pages (5), shared stage components (3), SSE route, live hook. Phase 6 refit: 23 routers/services migrated from roundId to stageId, all frontend components refitted. Deleted round.ts (985 lines), roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx, 10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs. Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing, TypeScript 0 errors, Next.js build succeeds, 13 integrity checks, legacy symbol sweep clean, auto-seed on first Docker startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
GitCompare,
|
||||
UserCheck,
|
||||
Globe,
|
||||
Layers,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import {
|
||||
@@ -51,7 +53,7 @@ import {
|
||||
ProjectRankingsChart,
|
||||
CriteriaScoresChart,
|
||||
GeographicDistribution,
|
||||
CrossRoundComparisonChart,
|
||||
CrossStageComparisonChart,
|
||||
JurorConsistencyChart,
|
||||
DiversityMetricsChart,
|
||||
} from '@/components/charts'
|
||||
@@ -59,11 +61,13 @@ import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
function ReportsOverview() {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
const { data: dashStats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery()
|
||||
|
||||
// Flatten rounds from all programs
|
||||
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programId: p.id, programName: `${p.year} Edition` }))) || []
|
||||
// Flatten stages from all programs
|
||||
const rounds = programs?.flatMap(p =>
|
||||
((p.stages ?? []) as Array<{ id: string; name: string; status: string; votingEndAt?: string | Date | null }>).map((s: { id: string; name: string; status: string; votingEndAt?: string | Date | null }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
|
||||
) || []
|
||||
|
||||
// Project reporting scope (default: latest program, all rounds)
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
@@ -73,7 +77,7 @@ function ReportsOverview() {
|
||||
}
|
||||
|
||||
const scopeInput = parseSelection(selectedValue)
|
||||
const hasScope = !!scopeInput.roundId || !!scopeInput.programId
|
||||
const hasScope = !!scopeInput.stageId || !!scopeInput.programId
|
||||
|
||||
const { data: projectRankings, isLoading: projectsLoading } =
|
||||
trpc.analytics.getProjectRankings.useQuery(
|
||||
@@ -103,7 +107,7 @@ function ReportsOverview() {
|
||||
|
||||
const totalPrograms = dashStats?.programCount ?? programs?.length ?? 0
|
||||
const totalProjects = dashStats?.projectCount ?? 0
|
||||
const activeRounds = dashStats?.activeRoundCount ?? rounds.filter((r) => r.status === 'ACTIVE').length
|
||||
const activeRounds = dashStats?.activeStageCount ?? rounds.filter((r: { status: string }) => r.status === 'STAGE_ACTIVE').length
|
||||
const jurorCount = dashStats?.jurorCount ?? 0
|
||||
const submittedEvaluations = dashStats?.submittedEvaluations ?? 0
|
||||
const totalEvaluations = dashStats?.totalEvaluations ?? 0
|
||||
@@ -365,7 +369,7 @@ function ReportsOverview() {
|
||||
<div className="flex justify-end gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={`/api/export/evaluations?roundId=${round.id}`}
|
||||
href={`/api/export/evaluations?stageId=${round.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -375,7 +379,7 @@ function ReportsOverview() {
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={`/api/export/results?roundId=${round.id}`}
|
||||
href={`/api/export/results?stageId=${round.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -396,28 +400,30 @@ function ReportsOverview() {
|
||||
)
|
||||
}
|
||||
|
||||
// Parse selection value: "all:programId" for edition-wide, or roundId
|
||||
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
||||
// Parse selection value: "all:programId" for edition-wide, or stageId
|
||||
function parseSelection(value: string | null): { stageId?: string; programId?: string } {
|
||||
if (!value) return {}
|
||||
if (value.startsWith('all:')) return { programId: value.slice(4) }
|
||||
return { roundId: value }
|
||||
return { stageId: value }
|
||||
}
|
||||
|
||||
function RoundAnalytics() {
|
||||
function StageAnalytics() {
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
// Flatten rounds from all programs with program name
|
||||
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programId: p.id, programName: `${p.year} Edition` }))) || []
|
||||
// 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` }))
|
||||
) || []
|
||||
|
||||
// Set default selected round
|
||||
// Set default selected stage
|
||||
if (rounds.length && !selectedValue) {
|
||||
setSelectedValue(rounds[0].id)
|
||||
}
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
|
||||
const { data: scoreDistribution, isLoading: scoreLoading } =
|
||||
trpc.analytics.getScoreDistribution.useQuery(
|
||||
@@ -458,11 +464,11 @@ function RoundAnalytics() {
|
||||
const selectedRound = rounds.find((r) => r.id === selectedValue)
|
||||
const geoInput = queryInput.programId
|
||||
? { programId: queryInput.programId }
|
||||
: { programId: selectedRound?.programId || '', roundId: queryInput.roundId }
|
||||
: { programId: selectedRound?.programId || '', stageId: queryInput.stageId }
|
||||
const { data: geoData, isLoading: geoLoading } =
|
||||
trpc.analytics.getGeographicDistribution.useQuery(
|
||||
geoInput,
|
||||
{ enabled: hasSelection && !!(geoInput.programId || geoInput.roundId) }
|
||||
{ enabled: hasSelection && !!(geoInput.programId || geoInput.stageId) }
|
||||
)
|
||||
|
||||
if (roundsLoading) {
|
||||
@@ -600,26 +606,26 @@ function RoundAnalytics() {
|
||||
)
|
||||
}
|
||||
|
||||
function CrossRoundTab() {
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
function CrossStageTab() {
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
|
||||
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, programName: `${p.year} Edition` }))
|
||||
) || []
|
||||
|
||||
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
|
||||
const [selectedStageIds, setSelectedStageIds] = useState<string[]>([])
|
||||
|
||||
const { data: comparison, isLoading: comparisonLoading } =
|
||||
trpc.analytics.getCrossRoundComparison.useQuery(
|
||||
{ roundIds: selectedRoundIds },
|
||||
{ enabled: selectedRoundIds.length >= 2 }
|
||||
trpc.analytics.getCrossStageComparison.useQuery(
|
||||
{ stageIds: selectedStageIds },
|
||||
{ enabled: selectedStageIds.length >= 2 }
|
||||
)
|
||||
|
||||
const toggleRound = (roundId: string) => {
|
||||
setSelectedRoundIds((prev) =>
|
||||
prev.includes(roundId)
|
||||
? prev.filter((id) => id !== roundId)
|
||||
: [...prev, roundId]
|
||||
const toggleStage = (stageId: string) => {
|
||||
setSelectedStageIds((prev) =>
|
||||
prev.includes(stageId)
|
||||
? prev.filter((id) => id !== stageId)
|
||||
: [...prev, stageId]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -629,40 +635,40 @@ function CrossRoundTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Round selector */}
|
||||
{/* Stage selector */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Select Rounds to Compare</CardTitle>
|
||||
<CardTitle>Select Stages to Compare</CardTitle>
|
||||
<CardDescription>
|
||||
Choose at least 2 rounds to compare metrics side by side
|
||||
Choose at least 2 stages to compare metrics side by side
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rounds.map((round) => {
|
||||
const isSelected = selectedRoundIds.includes(round.id)
|
||||
{stages.map((stage) => {
|
||||
const isSelected = selectedStageIds.includes(stage.id)
|
||||
return (
|
||||
<Badge
|
||||
key={round.id}
|
||||
key={stage.id}
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
className="cursor-pointer text-sm py-1.5 px-3"
|
||||
onClick={() => toggleRound(round.id)}
|
||||
onClick={() => toggleStage(stage.id)}
|
||||
>
|
||||
{round.programName} - {round.name}
|
||||
{stage.programName} - {stage.name}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{selectedRoundIds.length < 2 && (
|
||||
{selectedStageIds.length < 2 && (
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
Select at least 2 rounds to enable comparison
|
||||
Select at least 2 stages to enable comparison
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Comparison charts */}
|
||||
{comparisonLoading && selectedRoundIds.length >= 2 && (
|
||||
{comparisonLoading && selectedStageIds.length >= 2 && (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-[350px]" />
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
@@ -673,9 +679,9 @@ function CrossRoundTab() {
|
||||
)}
|
||||
|
||||
{comparison && (
|
||||
<CrossRoundComparisonChart data={comparison as Array<{
|
||||
roundId: string
|
||||
roundName: string
|
||||
<CrossStageComparisonChart data={comparison as Array<{
|
||||
stageId: string
|
||||
stageName: string
|
||||
projectCount: number
|
||||
evaluationCount: number
|
||||
completionRate: number
|
||||
@@ -690,18 +696,18 @@ function CrossRoundTab() {
|
||||
function JurorConsistencyTab() {
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` }))
|
||||
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` }))
|
||||
) || []
|
||||
|
||||
if (rounds.length && !selectedValue) {
|
||||
setSelectedValue(rounds[0].id)
|
||||
if (stages.length && !selectedValue) {
|
||||
setSelectedValue(stages[0].id)
|
||||
}
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
|
||||
const { data: consistency, isLoading: consistencyLoading } =
|
||||
trpc.analytics.getJurorConsistency.useQuery(
|
||||
@@ -716,20 +722,20 @@ function JurorConsistencyTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium">Select Round:</label>
|
||||
<label className="text-sm font-medium">Select Stage:</label>
|
||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="w-[300px]">
|
||||
<SelectValue placeholder="Select a round" />
|
||||
<SelectValue placeholder="Select a stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
||||
{p.year} Edition — All Rounds
|
||||
{p.year} Edition — All Stages
|
||||
</SelectItem>
|
||||
))}
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
{stages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.programName} - {stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -762,18 +768,18 @@ function JurorConsistencyTab() {
|
||||
function DiversityTab() {
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` }))
|
||||
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` }))
|
||||
) || []
|
||||
|
||||
if (rounds.length && !selectedValue) {
|
||||
setSelectedValue(rounds[0].id)
|
||||
if (stages.length && !selectedValue) {
|
||||
setSelectedValue(stages[0].id)
|
||||
}
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
|
||||
const { data: diversity, isLoading: diversityLoading } =
|
||||
trpc.analytics.getDiversityMetrics.useQuery(
|
||||
@@ -788,20 +794,20 @@ function DiversityTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium">Select Round:</label>
|
||||
<label className="text-sm font-medium">Select Stage:</label>
|
||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="w-[300px]">
|
||||
<SelectValue placeholder="Select a round" />
|
||||
<SelectValue placeholder="Select a stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
||||
{p.year} Edition — All Rounds
|
||||
{p.year} Edition — All Stages
|
||||
</SelectItem>
|
||||
))}
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
{stages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.programName} - {stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -826,18 +832,18 @@ function DiversityTab() {
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [pdfRoundId, setPdfRoundId] = useState<string | null>(null)
|
||||
const [pdfStageId, setPdfStageId] = useState<string | null>(null)
|
||||
|
||||
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
const pdfRounds = pdfPrograms?.flatMap((p) =>
|
||||
p.rounds.map((r) => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
|
||||
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` }))
|
||||
) || []
|
||||
|
||||
if (pdfRounds.length && !pdfRoundId) {
|
||||
setPdfRoundId(pdfRounds[0].id)
|
||||
if (pdfStages.length && !pdfStageId) {
|
||||
setPdfStageId(pdfStages[0].id)
|
||||
}
|
||||
|
||||
const selectedPdfRound = pdfRounds.find((r) => r.id === pdfRoundId)
|
||||
const selectedPdfStage = pdfStages.find((r) => r.id === pdfStageId)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -861,7 +867,7 @@ export default function ReportsPage() {
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cross-round" className="gap-2">
|
||||
<TabsTrigger value="cross-stage" className="gap-2">
|
||||
<GitCompare className="h-4 w-4" />
|
||||
Cross-Round
|
||||
</TabsTrigger>
|
||||
@@ -873,25 +879,31 @@ export default function ReportsPage() {
|
||||
<Globe className="h-4 w-4" />
|
||||
Diversity
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pipeline" className="gap-2" asChild>
|
||||
<Link href={"/admin/reports/stages" as Route}>
|
||||
<Layers className="h-4 w-4" />
|
||||
By Pipeline
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto justify-between sm:justify-end">
|
||||
<Select value={pdfRoundId || ''} onValueChange={setPdfRoundId}>
|
||||
<Select value={pdfStageId || ''} onValueChange={setPdfStageId}>
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<SelectValue placeholder="Select round for PDF" />
|
||||
<SelectValue placeholder="Select stage for PDF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pdfRounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
{pdfStages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.programName} - {stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{pdfRoundId && (
|
||||
{pdfStageId && (
|
||||
<ExportPdfButton
|
||||
roundId={pdfRoundId}
|
||||
roundName={selectedPdfRound?.name}
|
||||
programName={selectedPdfRound?.programName}
|
||||
stageId={pdfStageId}
|
||||
roundName={selectedPdfStage?.name}
|
||||
programName={selectedPdfStage?.programName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -902,11 +914,11 @@ export default function ReportsPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics">
|
||||
<RoundAnalytics />
|
||||
<StageAnalytics />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="cross-round">
|
||||
<CrossRoundTab />
|
||||
<TabsContent value="cross-stage">
|
||||
<CrossStageTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="consistency">
|
||||
|
||||
Reference in New Issue
Block a user