From 2e4b95f29cae46712498a496cc2d8e764e3860de Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 21 Feb 2026 09:29:26 +0100 Subject: [PATCH] Add round-type-specific observer reports with dynamic tabs Refactor the observer reports page from a static 3-tab layout to a dynamic tab system that adapts to each round type (INTAKE, FILTERING, EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION). Adds a persistent Global tab for edition-wide analytics, juror score heatmap, expandable juror assignment rows, filtering screening bar, and deliberation results with tie detection. - Add 5 observer proxy procedures to analytics router - Create JurorScoreHeatmap, ExpandableJurorTable, FilteringScreeningBar - Create 8 round-type tab components + GlobalAnalyticsTab - Reduce reports page from 914 to ~190 lines (thin dispatcher) Co-Authored-By: Claude Opus 4.6 --- src/app/(observer)/observer/reports/page.tsx | 929 +++--------------- src/components/charts/index.ts | 1 + src/components/charts/juror-score-heatmap.tsx | 116 +++ .../reports/deliberation-report-tabs.tsx | 303 ++++++ .../reports/evaluation-report-tabs.tsx | 764 ++++++++++++++ .../reports/expandable-juror-table.tsx | 232 +++++ .../reports/filtering-report-tabs.tsx | 231 +++++ .../reports/filtering-screening-bar.tsx | 115 +++ .../observer/reports/global-analytics-tab.tsx | 69 ++ .../observer/reports/intake-report-tabs.tsx | 37 + .../reports/live-final-report-tabs.tsx | 29 + .../reports/mentoring-report-tabs.tsx | 29 + .../reports/submission-report-tabs.tsx | 29 + src/server/routers/analytics.ts | 228 +++++ 14 files changed, 2326 insertions(+), 786 deletions(-) create mode 100644 src/components/charts/juror-score-heatmap.tsx create mode 100644 src/components/observer/reports/deliberation-report-tabs.tsx create mode 100644 src/components/observer/reports/evaluation-report-tabs.tsx create mode 100644 src/components/observer/reports/expandable-juror-table.tsx create mode 100644 src/components/observer/reports/filtering-report-tabs.tsx create mode 100644 src/components/observer/reports/filtering-screening-bar.tsx create mode 100644 src/components/observer/reports/global-analytics-tab.tsx create mode 100644 src/components/observer/reports/intake-report-tabs.tsx create mode 100644 src/components/observer/reports/live-final-report-tabs.tsx create mode 100644 src/components/observer/reports/mentoring-report-tabs.tsx create mode 100644 src/components/observer/reports/submission-report-tabs.tsx diff --git a/src/app/(observer)/observer/reports/page.tsx b/src/app/(observer)/observer/reports/page.tsx index 05fc125..26c8f00 100644 --- a/src/app/(observer)/observer/reports/page.tsx +++ b/src/app/(observer)/observer/reports/page.tsx @@ -1,26 +1,9 @@ 'use client' -import { useState, useEffect, Suspense, useCallback } from 'react' +import { useState, useEffect, Suspense } from 'react' import { useSearchParams } from 'next/navigation' import { trpc } from '@/lib/trpc/client' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { Progress } from '@/components/ui/progress' import { Skeleton } from '@/components/ui/skeleton' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' import { Select, SelectContent, @@ -29,32 +12,28 @@ import { SelectValue, } from '@/components/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Button } from '@/components/ui/button' import { - FileSpreadsheet, - BarChart3, - Users, + Globe, + LayoutDashboard, + Filter, + FolderOpen, TrendingUp, - Download, - Clock, + Users, + BarChart3, + Upload, + Presentation, + Vote, } from 'lucide-react' -import { formatDateOnly } from '@/lib/utils' -import { - ScoreDistributionChart, - EvaluationTimelineChart, - StatusBreakdownChart, - CriteriaScoresChart, -} from '@/components/charts' -import { BarChart } from '@tremor/react' -import { CsvExportDialog } from '@/components/shared/csv-export-dialog' -import { ExportPdfButton } from '@/components/shared/export-pdf-button' -import { AnimatedCard } from '@/components/shared/animated-container' +import type { LucideIcon } from 'lucide-react' -function parseSelection(value: string | null): { roundId?: string; programId?: string } { - if (!value) return {} - if (value.startsWith('all:')) return { programId: value.slice(4) } - return { roundId: value } -} +import { GlobalAnalyticsTab } from '@/components/observer/reports/global-analytics-tab' +import { IntakeReportTabs } from '@/components/observer/reports/intake-report-tabs' +import { FilteringReportTabs } from '@/components/observer/reports/filtering-report-tabs' +import { EvaluationReportTabs } from '@/components/observer/reports/evaluation-report-tabs' +import { SubmissionReportTabs } from '@/components/observer/reports/submission-report-tabs' +import { MentoringReportTabs } from '@/components/observer/reports/mentoring-report-tabs' +import { LiveFinalReportTabs } from '@/components/observer/reports/live-final-report-tabs' +import { DeliberationReportTabs } from '@/components/observer/reports/deliberation-report-tabs' const ROUND_TYPE_LABELS: Record = { INTAKE: 'Intake', @@ -77,707 +56,80 @@ type Stage = { programName: string } -function roundStatusLabel(status: string): string { - if (status === 'ROUND_ACTIVE') return 'Active' - if (status === 'ROUND_CLOSED') return 'Closed' - if (status === 'ROUND_DRAFT') return 'Draft' - if (status === 'ROUND_ARCHIVED') return 'Archived' - return status -} +type TabDef = { value: string; label: string; icon: LucideIcon } -function roundStatusVariant(status: string): 'default' | 'secondary' | 'outline' { - if (status === 'ROUND_ACTIVE') return 'default' - if (status === 'ROUND_CLOSED') return 'secondary' - return 'outline' -} - -function ProgressTab({ selectedValue, stages, stagesLoading, selectedRound }: { - selectedValue: string | null - stages: Stage[] - stagesLoading: boolean - selectedRound: Stage | undefined -}) { - const queryInput = parseSelection(selectedValue) - const hasSelection = !!queryInput.roundId || !!queryInput.programId - - const { data: overviewStats, isLoading: statsLoading } = - trpc.analytics.getOverviewStats.useQuery(queryInput, { enabled: hasSelection }) - - const { data: timeline, isLoading: timelineLoading } = - trpc.analytics.getEvaluationTimeline.useQuery(queryInput, { enabled: hasSelection }) - - const [csvOpen, setCsvOpen] = useState(false) - const [csvData, setCsvData] = useState<{ data: Record[]; columns: string[] } | undefined>() - const [csvLoading, setCsvLoading] = useState(false) - - const handleRequestCsvData = useCallback(async () => { - setCsvLoading(true) - const columns = ['roundName', 'roundType', 'status', 'projects', 'assignments', 'completionRate'] - const data = stages.map((s) => { - const assigned = s._count.assignments - const projects = s._count.projects - const rate = assigned > 0 && projects > 0 ? Math.round((assigned / projects) * 100) : 0 - return { - roundName: s.name, - roundType: ROUND_TYPE_LABELS[s.roundType] || s.roundType, - status: roundStatusLabel(s.status), - projects, - assignments: assigned, - completionRate: rate, - } - }) - const result = { data, columns } - setCsvData(result) - setCsvLoading(false) - return result - }, [stages]) - - return ( -
-
-
-

Progress Overview

-

Evaluation progress across rounds

-
-
- {selectedValue && !selectedValue.startsWith('all:') && ( - - )} - -
-
- - - - {/* Stats tiles */} - {hasSelection && ( - <> - {statsLoading ? ( -
- {[...Array(3)].map((_, i) => ( - - - - - - - - - - ))} -
- ) : overviewStats ? ( -
- - - -
-
-

Project Count

-

{overviewStats.projectCount}

-

In selection

-
-
- -
-
-
-
-
- - - - -
-
-

Evaluation Count

-

{overviewStats.evaluationCount}

-

Submitted

-
-
- -
-
-
-
-
- - - - -
-
-
-

Completion Rate

-

{overviewStats.completionRate}%

-
-
- -
-
- -
-
-
-
-
- ) : null} - - )} - - {/* Completion Timeline */} - {hasSelection && ( - <> - {timelineLoading ? ( - - ) : timeline?.length ? ( - - ) : ( - - -

No evaluation timeline data available yet

-
-
- )} - - )} - - {/* Round Breakdown Table - Desktop */} - {stagesLoading ? ( - - ) : ( - <> - - - Round Breakdown - Progress overview for each round - - - - - - Round - Type - Status - Projects - Assignments - Completion - Avg Days - - - - {stages.map((stage) => { - const projects = stage._count.projects - const assignments = stage._count.assignments - const evaluations = stage._count.evaluations - const isClosed = stage.status === 'ROUND_CLOSED' || stage.status === 'ROUND_ARCHIVED' - const rate = isClosed - ? 100 - : assignments > 0 - ? Math.min(100, Math.round((evaluations / assignments) * 100)) - : 0 - return ( - - -
-

{stage.name}

- {stage.windowCloseAt && ( -

- - {formatDateOnly(stage.windowCloseAt)} -

- )} -
-
- - - {ROUND_TYPE_LABELS[stage.roundType] || stage.roundType} - - - - - {roundStatusLabel(stage.status)} - - - {projects} - {assignments} - -
- - {rate}% -
-
- - -
- ) - })} -
-
-
-
- - {/* Round Breakdown Cards - Mobile */} -
-

Round Breakdown

- {stages.map((stage) => { - const projects = stage._count.projects - const assignments = stage._count.assignments - const evaluations = stage._count.evaluations - const isClosed = stage.status === 'ROUND_CLOSED' || stage.status === 'ROUND_ARCHIVED' - const rate = isClosed - ? 100 - : assignments > 0 - ? Math.min(100, Math.round((evaluations / assignments) * 100)) - : 0 - return ( - - -
-

{stage.name}

- - {roundStatusLabel(stage.status)} - -
-
- - {ROUND_TYPE_LABELS[stage.roundType] || stage.roundType} - -
-
-
-

Projects

-

{projects}

-
-
-

Assignments

-

{assignments}

-
-
-
-
- Completion - {rate}% -
- -
- {stage.windowCloseAt && ( -

- - Closes: {formatDateOnly(stage.windowCloseAt)} -

- )} -
-
- ) - })} -
- - )} -
- ) -} - -function JurorsTab({ selectedValue }: { selectedValue: string }) { - const queryInput = parseSelection(selectedValue) - const hasSelection = !!queryInput.roundId || !!queryInput.programId - - const { data: workload, isLoading: workloadLoading } = - trpc.analytics.getJurorWorkload.useQuery(queryInput, { enabled: hasSelection }) - - const { data: consistency, isLoading: consistencyLoading } = - trpc.analytics.getJurorConsistency.useQuery(queryInput, { enabled: hasSelection }) - - const [csvOpen, setCsvOpen] = useState(false) - const [csvData, setCsvData] = useState<{ data: Record[]; columns: string[] } | undefined>() - const [csvLoading, setCsvLoading] = useState(false) - - type WorkloadItem = { id: string; name: string; assigned: number; completed: number; completionRate: number } - type ConsistencyJuror = { userId: string; name: string; evaluationCount: number; averageScore: number; stddev: number; isOutlier: boolean } - - const handleRequestCsvData = useCallback(async () => { - setCsvLoading(true) - const columns = ['name', 'assigned', 'completed', 'completionRate', 'avgScore', 'stddev', 'isOutlier'] - - const workloadMap = new Map() - if (workload) { - for (const w of (workload as unknown as WorkloadItem[])) { - workloadMap.set(w.id, w) - } - } - - const jurors = (consistency as { overallAverage: number; jurors: ConsistencyJuror[] } | undefined)?.jurors ?? [] - const data = jurors.map((j) => { - const w = workloadMap.get(j.userId) - return { - name: j.name, - assigned: w?.assigned ?? '-', - completed: w?.completed ?? '-', - completionRate: w ? `${w.completionRate}%` : '-', - avgScore: j.averageScore, - stddev: j.stddev, - isOutlier: j.isOutlier ? 'Yes' : 'No', - } - }) - - const result = { data, columns } - setCsvData(result) - setCsvLoading(false) - return result - }, [workload, consistency]) - - const isLoading = workloadLoading || consistencyLoading - - type JurorRow = { - userId: string - name: string - assigned: number - completed: number - completionRate: number - averageScore: number - stddev: number - isOutlier: boolean +function getRoundTabs(roundType: string): TabDef[] { + switch (roundType) { + case 'INTAKE': + return [{ value: 'overview', label: 'Overview', icon: LayoutDashboard }] + case 'FILTERING': + return [ + { value: 'screening', label: 'Screening', icon: Filter }, + ] + case 'EVALUATION': + return [ + { value: 'evaluation', label: 'Evaluation', icon: TrendingUp }, + ] + case 'SUBMISSION': + return [{ value: 'overview', label: 'Overview', icon: Upload }] + case 'MENTORING': + return [{ value: 'overview', label: 'Overview', icon: Users }] + case 'LIVE_FINAL': + return [{ value: 'session', label: 'Session', icon: Presentation }] + case 'DELIBERATION': + return [ + { value: 'deliberation', label: 'Deliberation', icon: Vote }, + ] + default: + return [] } - - const jurors: JurorRow[] = (() => { - if (!consistency) return [] - const workloadMap = new Map() - if (workload) { - for (const w of (workload as unknown as WorkloadItem[])) { - workloadMap.set(w.id, w) - } - } - const jurorList = (consistency as { overallAverage: number; jurors: ConsistencyJuror[] }).jurors ?? [] - return jurorList - .map((j) => { - const w = workloadMap.get(j.userId) - return { - userId: j.userId, - name: j.name, - assigned: w?.assigned ?? 0, - completed: w?.completed ?? 0, - completionRate: w?.completionRate ?? 0, - averageScore: j.averageScore, - stddev: j.stddev, - isOutlier: j.isOutlier, - } - }) - .sort((a, b) => b.assigned - a.assigned) - })() - - return ( -
-
-
-

Juror Performance

-

Workload and scoring consistency per juror

-
- -
- - - - {/* Juror Performance Table */} - {isLoading ? ( - - ) : jurors.length > 0 ? ( - <> - {/* Desktop Table */} - - - - - - Juror - Assigned - Completed - Rate - Avg Score - Std Dev - Status - - - - {jurors.map((j) => ( - - {j.name} - {j.assigned} - {j.completed} - -
- - {j.completionRate}% -
-
- {j.averageScore.toFixed(2)} - {j.stddev.toFixed(2)} - - {j.isOutlier ? ( - Outlier - ) : ( - Normal - )} - -
- ))} -
-
-
-
- - {/* Mobile Cards */} -
- {jurors.map((j) => ( - - -
-

{j.name}

- {j.isOutlier ? ( - Outlier - ) : ( - Normal - )} -
-
- - {j.completionRate}% -
-
-
- Assigned - {j.assigned} -
-
- Completed - {j.completed} -
-
- Avg Score - {j.averageScore.toFixed(2)} -
-
- Std Dev - {j.stddev.toFixed(2)} -
-
-
-
- ))} -
- - ) : hasSelection ? ( - - -

No juror data available for this selection

-
-
- ) : null} - - {/* Heatmap placeholder */} - - - -

Juror scoring heatmap

-

Coming soon

-
-
-
- ) } -function ScoresTab({ selectedValue, programId }: { selectedValue: string; programId: string | undefined }) { - const queryInput = parseSelection(selectedValue) - const hasSelection = !!queryInput.roundId || !!queryInput.programId - - const { data: scoreDistribution, isLoading: scoreLoading } = - trpc.analytics.getScoreDistribution.useQuery(queryInput, { enabled: hasSelection }) - - const { data: statusBreakdown, isLoading: statusLoading } = - trpc.analytics.getStatusBreakdown.useQuery(queryInput, { enabled: hasSelection }) - - const { data: criteriaScores, isLoading: criteriaLoading } = - trpc.analytics.getCriteriaScores.useQuery(queryInput, { enabled: hasSelection }) - - const geoProgramId = queryInput.programId || programId - const { data: geoData, isLoading: geoLoading } = - trpc.analytics.getGeographicDistribution.useQuery( - { programId: geoProgramId!, roundId: queryInput.roundId }, - { enabled: !!geoProgramId } - ) - - const [csvOpen, setCsvOpen] = useState(false) - const [csvData, setCsvData] = useState<{ data: Record[]; columns: string[] } | undefined>() - const [csvLoading, setCsvLoading] = useState(false) - - type CriterionItem = { criterionName: string; averageScore: number; count: number } - - const handleRequestCsvData = useCallback(async () => { - setCsvLoading(true) - const columns = ['criterionName', 'averageScore', 'count'] - const data = ((criteriaScores as CriterionItem[] | undefined) ?? []).map((c) => ({ - criterionName: c.criterionName, - averageScore: c.averageScore, - count: c.count, - })) - const result = { data, columns } - setCsvData(result) - setCsvLoading(false) - return result - }, [criteriaScores]) - - // Country chart data - const countryChartData = (() => { - if (!geoData?.length) return [] - const sorted = [...geoData].sort((a, b) => b.count - a.count) - return sorted.slice(0, 15).map((d) => { - let name = d.countryCode - try { - const displayNames = new Intl.DisplayNames(['en'], { type: 'region' }) - name = displayNames.of(d.countryCode.toUpperCase()) || d.countryCode - } catch { /* keep code */ } - return { country: name, Projects: d.count } - }) - })() - - return ( -
-
-
-

Scores & Analytics

-

Score distributions, criteria breakdown and geographic data

-
- -
- - - - {/* Score Distribution & Status Breakdown */} -
- {scoreLoading ? ( - - ) : scoreDistribution ? ( - - ) : hasSelection ? ( - - -

No score data available yet

-
-
- ) : null} - - {statusLoading ? ( - - ) : statusBreakdown ? ( - - ) : hasSelection ? ( - - -

No status data available yet

-
-
- ) : null} -
- - {/* Criteria Breakdown */} - {criteriaLoading ? ( - - ) : criteriaScores?.length ? ( - - ) : hasSelection ? ( - - -

No criteria score data available yet

-
-
- ) : null} - - {/* Country Distribution */} - {geoLoading ? ( - - ) : countryChartData.length > 0 ? ( - - - - Top Countries - - {geoData?.length ?? 0} countries represented - - - - - - - - ) : null} -
- ) +function RoundTypeContent({ + roundType, + roundId, + programId, + stages, + selectedValue, +}: { + roundType: string + roundId: string + programId: string + stages: Stage[] + selectedValue: string | null +}) { + switch (roundType) { + case 'INTAKE': + return + case 'FILTERING': + return + case 'EVALUATION': + return ( + + ) + case 'SUBMISSION': + return + case 'MENTORING': + return + case 'LIVE_FINAL': + return + case 'DELIBERATION': + return + default: + return null + } } function ReportsPageContent() { const searchParams = useSearchParams() const roundFromUrl = searchParams.get('round') const [selectedValue, setSelectedValue] = useState(roundFromUrl) + const [activeTab, setActiveTab] = useState('global') const { data: programs, isLoading: stagesLoading } = trpc.program.list.useQuery({ includeStages: true }) @@ -789,6 +141,8 @@ function ReportsPageContent() { })) ) ?? [] + const allRoundIds = stages.map((s) => s.id) + useEffect(() => { if (stages.length && !selectedValue) { const active = stages.find((s) => s.status === 'ROUND_ACTIVE') @@ -796,7 +150,26 @@ function ReportsPageContent() { } }, [stages.length, selectedValue]) + // Reset to global tab when round selection changes + useEffect(() => { + setActiveTab('global') + }, [selectedValue]) + + const isAllRounds = selectedValue?.startsWith('all:') const selectedRound = stages.find((s) => s.id === selectedValue) + const roundType = selectedRound?.roundType ?? '' + const programId = isAllRounds + ? selectedValue!.slice(4) + : selectedRound?.programId ?? programs?.[0]?.id ?? '' + + const roundSpecificTabs = isAllRounds + ? [{ value: 'progress', label: 'Progress', icon: TrendingUp }] + : getRoundTabs(roundType) + + const allTabs: TabDef[] = [ + { value: 'global', label: 'Global', icon: Globe }, + ...roundSpecificTabs, + ] return (
@@ -832,63 +205,47 @@ function ReportsPageContent() { )}
- - - - - Progress - - - - Jurors - - - - Scores & Analytics - - + {selectedValue && ( + + + {allTabs.map((tab) => ( + + + {tab.label} + + ))} + - - - + + = 2 ? allRoundIds : undefined} + /> + - - {selectedValue ? ( - - ) : ( - - - -

Select a round

-

- Choose a round or edition from the dropdown above to view juror data -

-
-
- )} -
- - - {selectedValue ? ( - - ) : ( - - - -

Select a round

-

- Choose a round or edition from the dropdown above to view scores and analytics -

-
-
- )} -
-
+ {/* Round-type-specific or "All Rounds" progress tab */} + {roundSpecificTabs.map((tab) => ( + + {isAllRounds ? ( + + ) : selectedRound ? ( + + ) : null} + + ))} +
+ )} ) } diff --git a/src/components/charts/index.ts b/src/components/charts/index.ts index ef7cbc3..d669640 100644 --- a/src/components/charts/index.ts +++ b/src/components/charts/index.ts @@ -10,3 +10,4 @@ export { GeographicSummaryCard } from './geographic-summary-card' export { CrossStageComparisonChart } from './cross-round-comparison' export { JurorConsistencyChart } from './juror-consistency' export { DiversityMetricsChart } from './diversity-metrics' +export { JurorScoreHeatmap } from './juror-score-heatmap' diff --git a/src/components/charts/juror-score-heatmap.tsx b/src/components/charts/juror-score-heatmap.tsx new file mode 100644 index 0000000..4b6e4f5 --- /dev/null +++ b/src/components/charts/juror-score-heatmap.tsx @@ -0,0 +1,116 @@ +'use client' + +import { Fragment } from 'react' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { scoreGradient } from './chart-theme' + +interface JurorScoreHeatmapProps { + jurors: { id: string; name: string }[] + projects: { id: string; title: string }[] + cells: { jurorId: string; projectId: string; score: number | null }[] + truncated?: boolean + totalProjects?: number +} + +function getScoreColor(score: number | null): string { + if (score === null) return 'transparent' + return scoreGradient(score) +} + +function getTextColor(score: number | null): string { + if (score === null) return 'inherit' + return score >= 6 ? '#ffffff' : '#1a1a1a' +} + +export function JurorScoreHeatmap({ + jurors, + projects, + cells, + truncated, + totalProjects, +}: JurorScoreHeatmapProps) { + const cellMap = new Map() + for (const c of cells) { + cellMap.set(`${c.jurorId}:${c.projectId}`, c.score) + } + + if (jurors.length === 0 || projects.length === 0) { + return ( + + +

No score data available for heatmap

+
+
+ ) + } + + return ( + + + Score Heatmap + + {jurors.length} jurors × {projects.length} projects + {truncated && totalProjects ? ` (showing top 30 of ${totalProjects})` : ''} + + + + {/* Mobile fallback */} +
+

+ View on a larger screen for the score heatmap +

+
+ + {/* Desktop heatmap */} +
+
+ {/* Header row */} +
+ {projects.map((p) => ( +
+ {p.title.length > 12 ? p.title.slice(0, 10) + '…' : p.title} +
+ ))} + + {/* Data rows */} + {jurors.map((j) => ( + +
+ {j.name} +
+ {projects.map((p) => { + const score = cellMap.get(`${j.id}:${p.id}`) ?? null + return ( +
+ {score !== null ? score.toFixed(1) : '—'} +
+ ) + })} +
+ ))} +
+
+ + + ) +} diff --git a/src/components/observer/reports/deliberation-report-tabs.tsx b/src/components/observer/reports/deliberation-report-tabs.tsx new file mode 100644 index 0000000..1499be7 --- /dev/null +++ b/src/components/observer/reports/deliberation-report-tabs.tsx @@ -0,0 +1,303 @@ +'use client' + +import { useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Users, Trophy } from 'lucide-react' +import { RoundTypeStatsCards } from '@/components/observer/round-type-stats' + +interface DeliberationReportTabsProps { + roundId: string + programId: string +} + +function sessionStatusBadge(status: string) { + switch (status) { + case 'DELIB_LOCKED': + return Locked + case 'VOTING': + return Voting + case 'TALLYING': + return Tallying + case 'RUNOFF': + return Runoff + case 'DELIB_OPEN': + return Open + default: + return {status} + } +} + +function sessionModeBadge(mode: string) { + return {mode.charAt(0) + mode.slice(1).toLowerCase()} +} + +function SessionsTab({ roundId }: { roundId: string }) { + const { data: sessions, isLoading } = + trpc.analytics.getDeliberationSessions.useQuery({ roundId }) + + if (isLoading) return + + if (!sessions?.length) { + return ( + + + +

No deliberation sessions yet

+

+ Sessions will appear here once created by administrators +

+
+
+ ) + } + + return ( + <> + {/* Desktop table */} +
+ + + + Category + Mode + Status + Participants + Votes Cast + + + + {sessions.map((session) => ( + + + {session.category ?? General} + + {sessionModeBadge(session.mode)} + {sessionStatusBadge(session.status)} + {session._count.participants} + {session._count.votes} + + ))} + +
+
+ + {/* Mobile card stack */} +
+ {sessions.map((session) => ( + + +
+

+ {session.category ?? General} +

+ {sessionStatusBadge(session.status)} +
+
+ {sessionModeBadge(session.mode)} +
+
+
+

Participants

+

{session._count.participants}

+
+
+

Votes Cast

+

{session._count.votes}

+
+
+
+
+ ))} +
+ + ) +} + +function ResultsTab({ roundId }: { roundId: string }) { + const [selectedSessionId, setSelectedSessionId] = useState(null) + + const { data: sessions, isLoading: sessionsLoading } = + trpc.analytics.getDeliberationSessions.useQuery({ roundId }) + + const activeSessions = sessions?.filter((s) => s._count.votes > 0) ?? [] + const activeSessionId = selectedSessionId ?? activeSessions[0]?.id ?? null + + const { data: aggregate, isLoading: aggregateLoading } = + trpc.analytics.getDeliberationAggregate.useQuery( + { sessionId: activeSessionId! }, + { enabled: !!activeSessionId } + ) + + if (sessionsLoading) return + + if (!activeSessions.length) { + return ( + + +

+ No votes have been cast yet. Results will appear once deliberation is underway. +

+
+
+ ) + } + + const currentSessionId = selectedSessionId ?? activeSessions[0]?.id + + return ( +
+ {/* Session selector if multiple */} + {activeSessions.length > 1 && ( +
+ {activeSessions.map((s) => ( + + ))} +
+ )} + + {aggregateLoading ? ( + + ) : aggregate ? ( + + + + + Ranking Results + + + {aggregate.rankings.length} project{aggregate.rankings.length !== 1 ? 's' : ''} ranked + {aggregate.hasTies && ( + · Ties detected + )} + + + + {/* Desktop table */} +
+ + + + Rank + Project + Team + Score + Votes + + + + {aggregate.rankings.map((r, idx) => { + const isTied = aggregate.tiedProjectIds.includes(r.projectId) + return ( + + + {idx + 1} + + +
+ {r.projectTitle} + {isTied && ( + + Tie + + )} +
+
+ {r.teamName} + + {typeof r.score === 'number' ? r.score : '—'} + + {r.voteCount} +
+ ) + })} +
+
+
+ + {/* Mobile list */} +
+ {aggregate.rankings.map((r, idx) => { + const isTied = aggregate.tiedProjectIds.includes(r.projectId) + return ( +
+ + {idx + 1} + +
+
+

{r.projectTitle}

+ {isTied && ( + + Tie + + )} +
+

{r.teamName}

+
+

+ {r.voteCount} votes +

+
+ ) + })} +
+
+
+ ) : null} +
+ ) +} + +export function DeliberationReportTabs({ roundId }: DeliberationReportTabsProps) { + return ( +
+ + + + + + + Sessions + + + + Results + + + + + + + + + + + +
+ ) +} diff --git a/src/components/observer/reports/evaluation-report-tabs.tsx b/src/components/observer/reports/evaluation-report-tabs.tsx new file mode 100644 index 0000000..69fd879 --- /dev/null +++ b/src/components/observer/reports/evaluation-report-tabs.tsx @@ -0,0 +1,764 @@ +'use client' + +import { useState, useCallback } from 'react' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Progress } from '@/components/ui/progress' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Button } from '@/components/ui/button' +import { + FileSpreadsheet, + BarChart3, + Users, + TrendingUp, + Download, + Clock, +} from 'lucide-react' +import { formatDateOnly } from '@/lib/utils' +import { + ScoreDistributionChart, + EvaluationTimelineChart, + StatusBreakdownChart, + CriteriaScoresChart, + JurorConsistencyChart, + JurorScoreHeatmap, +} from '@/components/charts' +import { BarChart } from '@tremor/react' +import { CsvExportDialog } from '@/components/shared/csv-export-dialog' +import { ExportPdfButton } from '@/components/shared/export-pdf-button' +import { AnimatedCard } from '@/components/shared/animated-container' +import { RoundTypeStatsCards } from '@/components/observer/round-type-stats' +import { ExpandableJurorTable } from './expandable-juror-table' + +const ROUND_TYPE_LABELS: Record = { + INTAKE: 'Intake', + FILTERING: 'Filtering', + EVALUATION: 'Evaluation', + SUBMISSION: 'Submission', + MENTORING: 'Mentoring', + LIVE_FINAL: 'Live Final', + DELIBERATION: 'Deliberation', +} + +type Stage = { + id: string + name: string + status: string + roundType: string + windowCloseAt: Date | null + _count: { projects: number; assignments: number; evaluations: number } + programId: string + programName: string +} + +function roundStatusLabel(status: string): string { + if (status === 'ROUND_ACTIVE') return 'Active' + if (status === 'ROUND_CLOSED') return 'Closed' + if (status === 'ROUND_DRAFT') return 'Draft' + if (status === 'ROUND_ARCHIVED') return 'Archived' + return status +} + +function roundStatusVariant(status: string): 'default' | 'secondary' | 'outline' { + if (status === 'ROUND_ACTIVE') return 'default' + if (status === 'ROUND_CLOSED') return 'secondary' + return 'outline' +} + +function parseSelection(value: string | null): { roundId?: string; programId?: string } { + if (!value) return {} + if (value.startsWith('all:')) return { programId: value.slice(4) } + return { roundId: value } +} + +interface EvaluationReportTabsProps { + roundId: string + programId: string + stages: Stage[] + selectedValue: string | null +} + +// ---- Progress sub-tab ---- + +function ProgressSubTab({ + selectedValue, + stages, + stagesLoading, + selectedRound, +}: { + selectedValue: string | null + stages: Stage[] + stagesLoading: boolean + selectedRound: Stage | undefined +}) { + const queryInput = parseSelection(selectedValue) + const hasSelection = !!queryInput.roundId || !!queryInput.programId + + const { data: overviewStats, isLoading: statsLoading } = + trpc.analytics.getOverviewStats.useQuery(queryInput, { enabled: hasSelection }) + + const { data: timeline, isLoading: timelineLoading } = + trpc.analytics.getEvaluationTimeline.useQuery(queryInput, { enabled: hasSelection }) + + const [csvOpen, setCsvOpen] = useState(false) + const [csvData, setCsvData] = useState<{ data: Record[]; columns: string[] } | undefined>() + const [csvLoading, setCsvLoading] = useState(false) + + const handleRequestCsvData = useCallback(async () => { + setCsvLoading(true) + const columns = ['roundName', 'roundType', 'status', 'projects', 'assignments', 'completionRate'] + const data = stages.map((s) => { + const assigned = s._count.assignments + const projects = s._count.projects + const rate = assigned > 0 && projects > 0 ? Math.round((assigned / projects) * 100) : 0 + return { + roundName: s.name, + roundType: ROUND_TYPE_LABELS[s.roundType] || s.roundType, + status: roundStatusLabel(s.status), + projects, + assignments: assigned, + completionRate: rate, + } + }) + const result = { data, columns } + setCsvData(result) + setCsvLoading(false) + return result + }, [stages]) + + return ( +
+
+
+

Progress Overview

+

Evaluation progress across rounds

+
+
+ {selectedValue && !selectedValue.startsWith('all:') && ( + + )} + +
+
+ + + + {/* Stats tiles */} + {hasSelection && ( + <> + {statsLoading ? ( +
+ {[...Array(3)].map((_, i) => ( + + + + + + + + + + ))} +
+ ) : overviewStats ? ( +
+ + + +
+
+

Project Count

+

{overviewStats.projectCount}

+

In selection

+
+
+ +
+
+
+
+
+ + + + +
+
+

Evaluation Count

+

{overviewStats.evaluationCount}

+

Submitted

+
+
+ +
+
+
+
+
+ + + + +
+
+
+

Completion Rate

+

{overviewStats.completionRate}%

+
+
+ +
+
+ +
+
+
+
+
+ ) : null} + + )} + + {/* Completion Timeline */} + {hasSelection && ( + <> + {timelineLoading ? ( + + ) : timeline?.length ? ( + + ) : ( + + +

No evaluation timeline data available yet

+
+
+ )} + + )} + + {/* Round Breakdown Table - Desktop */} + {stagesLoading ? ( + + ) : ( + <> + + + Round Breakdown + Progress overview for each round + + + + + + Round + Type + Status + Projects + Assignments + Completion + Avg Days + + + + {stages.map((stage) => { + const projects = stage._count.projects + const assignments = stage._count.assignments + const evaluations = stage._count.evaluations + const isClosed = stage.status === 'ROUND_CLOSED' || stage.status === 'ROUND_ARCHIVED' + const rate = isClosed + ? 100 + : assignments > 0 + ? Math.min(100, Math.round((evaluations / assignments) * 100)) + : 0 + return ( + + +
+

{stage.name}

+ {stage.windowCloseAt && ( +

+ + {formatDateOnly(stage.windowCloseAt)} +

+ )} +
+
+ + + {ROUND_TYPE_LABELS[stage.roundType] || stage.roundType} + + + + + {roundStatusLabel(stage.status)} + + + {projects} + {assignments} + +
+ + {rate}% +
+
+ - +
+ ) + })} +
+
+
+
+ + {/* Round Breakdown Cards - Mobile */} +
+

Round Breakdown

+ {stages.map((stage) => { + const projects = stage._count.projects + const assignments = stage._count.assignments + const evaluations = stage._count.evaluations + const isClosed = stage.status === 'ROUND_CLOSED' || stage.status === 'ROUND_ARCHIVED' + const rate = isClosed + ? 100 + : assignments > 0 + ? Math.min(100, Math.round((evaluations / assignments) * 100)) + : 0 + return ( + + +
+

{stage.name}

+ + {roundStatusLabel(stage.status)} + +
+
+ + {ROUND_TYPE_LABELS[stage.roundType] || stage.roundType} + +
+
+
+

Projects

+

{projects}

+
+
+

Assignments

+

{assignments}

+
+
+
+
+ Completion + {rate}% +
+ +
+ {stage.windowCloseAt && ( +

+ + Closes: {formatDateOnly(stage.windowCloseAt)} +

+ )} +
+
+ ) + })} +
+ + )} +
+ ) +} + +// ---- Jurors sub-tab ---- + +function JurorsSubTab({ roundId, selectedValue }: { roundId: string; selectedValue: string | null }) { + const queryInput = parseSelection(selectedValue) + const hasSelection = !!queryInput.roundId || !!queryInput.programId + + const { data: workload, isLoading: workloadLoading } = + trpc.analytics.getJurorWorkload.useQuery(queryInput, { enabled: hasSelection }) + + const { data: consistency, isLoading: consistencyLoading } = + trpc.analytics.getJurorConsistency.useQuery(queryInput, { enabled: hasSelection }) + + const { data: heatmapData, isLoading: heatmapLoading } = + trpc.analytics.getJurorScoreMatrix.useQuery({ roundId }, { enabled: !!roundId }) + + const [csvOpen, setCsvOpen] = useState(false) + const [csvData, setCsvData] = useState<{ data: Record[]; columns: string[] } | undefined>() + const [csvLoading, setCsvLoading] = useState(false) + + type WorkloadItem = { id: string; name: string; assigned: number; completed: number; completionRate: number; projects: { id: string; title: string; evalStatus: string }[] } + type ConsistencyJuror = { userId: string; name: string; evaluationCount: number; averageScore: number; stddev: number; isOutlier: boolean } + + const handleRequestCsvData = useCallback(async () => { + setCsvLoading(true) + const columns = ['name', 'assigned', 'completed', 'completionRate', 'avgScore', 'stddev', 'isOutlier'] + + const workloadMap = new Map() + if (workload) { + for (const w of (workload as unknown as WorkloadItem[])) { + workloadMap.set(w.id, w) + } + } + + const jurors = (consistency as { overallAverage: number; jurors: ConsistencyJuror[] } | undefined)?.jurors ?? [] + const data = jurors.map((j) => { + const w = workloadMap.get(j.userId) + return { + name: j.name, + assigned: w?.assigned ?? '-', + completed: w?.completed ?? '-', + completionRate: w ? `${w.completionRate}%` : '-', + avgScore: j.averageScore, + stddev: j.stddev, + isOutlier: j.isOutlier ? 'Yes' : 'No', + } + }) + + const result = { data, columns } + setCsvData(result) + setCsvLoading(false) + return result + }, [workload, consistency]) + + const isLoading = workloadLoading || consistencyLoading + + type JurorRow = { + userId: string + name: string + assigned: number + completed: number + completionRate: number + averageScore: number + stddev: number + isOutlier: boolean + projects: { id: string; title: string; evalStatus: string }[] + } + + const jurors: JurorRow[] = (() => { + if (!consistency) return [] + const workloadMap = new Map() + if (workload) { + for (const w of (workload as unknown as WorkloadItem[])) { + workloadMap.set(w.id, w) + } + } + const jurorList = (consistency as { overallAverage: number; jurors: ConsistencyJuror[] }).jurors ?? [] + return jurorList + .map((j) => { + const w = workloadMap.get(j.userId) + return { + userId: j.userId, + name: j.name, + assigned: w?.assigned ?? 0, + completed: w?.completed ?? 0, + completionRate: w?.completionRate ?? 0, + averageScore: j.averageScore, + stddev: j.stddev, + isOutlier: j.isOutlier, + projects: w?.projects ?? [], + } + }) + .sort((a, b) => b.assigned - a.assigned) + })() + + return ( +
+
+
+

Juror Performance

+

Workload and scoring consistency per juror

+
+ +
+ + + + {/* Expandable Juror Table */} + {isLoading ? ( + + ) : jurors.length > 0 ? ( + + ) : hasSelection ? ( + + +

No juror data available for this selection

+
+
+ ) : null} + + {/* Juror Score Heatmap */} + {heatmapLoading ? ( + + ) : heatmapData ? ( + + ) : null} + + {/* Juror Consistency Chart */} + {consistencyLoading ? ( + + ) : consistency ? ( + }} + /> + ) : null} +
+ ) +} + +// ---- Scores sub-tab ---- + +function ScoresSubTab({ selectedValue, programId }: { selectedValue: string | null; programId: string }) { + const queryInput = parseSelection(selectedValue) + const hasSelection = !!queryInput.roundId || !!queryInput.programId + + const { data: scoreDistribution, isLoading: scoreLoading } = + trpc.analytics.getScoreDistribution.useQuery(queryInput, { enabled: hasSelection }) + + const { data: statusBreakdown, isLoading: statusLoading } = + trpc.analytics.getStatusBreakdown.useQuery(queryInput, { enabled: hasSelection }) + + const { data: criteriaScores, isLoading: criteriaLoading } = + trpc.analytics.getCriteriaScores.useQuery(queryInput, { enabled: hasSelection }) + + const geoProgramId = queryInput.programId || programId + const { data: geoData, isLoading: geoLoading } = + trpc.analytics.getGeographicDistribution.useQuery( + { programId: geoProgramId, roundId: queryInput.roundId }, + { enabled: !!geoProgramId } + ) + + const [csvOpen, setCsvOpen] = useState(false) + const [csvData, setCsvData] = useState<{ data: Record[]; columns: string[] } | undefined>() + const [csvLoading, setCsvLoading] = useState(false) + + type CriterionItem = { criterionName: string; averageScore: number; count: number } + + const handleRequestCsvData = useCallback(async () => { + setCsvLoading(true) + const columns = ['criterionName', 'averageScore', 'count'] + const data = ((criteriaScores as CriterionItem[] | undefined) ?? []).map((c) => ({ + criterionName: c.criterionName, + averageScore: c.averageScore, + count: c.count, + })) + const result = { data, columns } + setCsvData(result) + setCsvLoading(false) + return result + }, [criteriaScores]) + + const countryChartData = (() => { + if (!geoData?.length) return [] + const sorted = [...geoData].sort((a, b) => b.count - a.count) + return sorted.slice(0, 15).map((d) => { + let name = d.countryCode + try { + const displayNames = new Intl.DisplayNames(['en'], { type: 'region' }) + name = displayNames.of(d.countryCode.toUpperCase()) || d.countryCode + } catch { /* keep code */ } + return { country: name, Projects: d.count } + }) + })() + + return ( +
+
+
+

Scores & Analytics

+

Score distributions, criteria breakdown and geographic data

+
+ +
+ + + + {/* Score Distribution & Status Breakdown */} +
+ {scoreLoading ? ( + + ) : scoreDistribution ? ( + + ) : hasSelection ? ( + + +

No score data available yet

+
+
+ ) : null} + + {statusLoading ? ( + + ) : statusBreakdown ? ( + + ) : hasSelection ? ( + + +

No status data available yet

+
+
+ ) : null} +
+ + {/* Criteria Breakdown */} + {criteriaLoading ? ( + + ) : criteriaScores?.length ? ( + + ) : hasSelection ? ( + + +

No criteria score data available yet

+
+
+ ) : null} + + {/* Country Distribution */} + {geoLoading ? ( + + ) : countryChartData.length > 0 ? ( + + + + Top Countries + + {geoData?.length ?? 0} countries represented + + + + + + + + ) : null} +
+ ) +} + +// ---- Main component ---- + +export function EvaluationReportTabs({ roundId, programId, stages, selectedValue }: EvaluationReportTabsProps) { + const selectedRound = stages.find((s) => s.id === selectedValue) + const stagesLoading = false // stages passed from parent already loaded + + return ( +
+ + + + + + + Progress + + + + Jurors + + + + Scores + + + + + + + + + + + + + + + +
+ ) +} diff --git a/src/components/observer/reports/expandable-juror-table.tsx b/src/components/observer/reports/expandable-juror-table.tsx new file mode 100644 index 0000000..279098e --- /dev/null +++ b/src/components/observer/reports/expandable-juror-table.tsx @@ -0,0 +1,232 @@ +'use client' + +import { useState } from 'react' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Progress } from '@/components/ui/progress' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { ChevronDown, ChevronUp } from 'lucide-react' +import Link from 'next/link' + +interface JurorRow { + userId: string + name: string + assigned: number + completed: number + completionRate: number + averageScore: number + stddev: number + isOutlier: boolean + projects: { id: string; title: string; evalStatus: string }[] +} + +interface ExpandableJurorTableProps { + jurors: JurorRow[] +} + +function evalStatusBadge(status: string) { + switch (status) { + case 'REVIEWED': + return Reviewed + case 'UNDER_REVIEW': + return Under Review + default: + return Not Reviewed + } +} + +export function ExpandableJurorTable({ jurors }: ExpandableJurorTableProps) { + const [expanded, setExpanded] = useState(null) + + function toggle(userId: string) { + setExpanded((prev) => (prev === userId ? null : userId)) + } + + if (jurors.length === 0) { + return ( + + +

No juror data available

+
+
+ ) + } + + return ( + <> + {/* Desktop table */} +
+ + + + Juror + Assigned + Completed + Rate + Avg Score + Std Dev + Status + + + + + {jurors.map((j) => ( + <> + toggle(j.userId)} + > + {j.name} + {j.assigned} + {j.completed} + +
+ + + {j.completionRate.toFixed(0)}% + +
+
+ + {j.averageScore > 0 ? j.averageScore.toFixed(2) : '—'} + + + {j.stddev > 0 ? j.stddev.toFixed(2) : '—'} + + + {j.isOutlier ? ( + Outlier + ) : ( + Normal + )} + + + {expanded === j.userId ? ( + + ) : ( + + )} + +
+ {expanded === j.userId && ( + + +
+ {j.projects.length === 0 ? ( +

No projects

+ ) : ( +
+ + + + + + + + {j.projects.map((p) => ( + + + + + ))} + +
ProjectEvaluation Status
+ e.stopPropagation()} + > + {p.title} + + {evalStatusBadge(p.evalStatus)}
+ )} +
+ + + )} + + ))} + + +
+ + {/* Mobile card stack */} +
+ {jurors.map((j) => ( + + + + + {expanded === j.userId && j.projects.length > 0 && ( +
+ {j.projects.map((p) => ( +
+ + {p.title} + + {evalStatusBadge(p.evalStatus)} +
+ ))} +
+ )} + {expanded === j.userId && j.projects.length === 0 && ( +

No projects

+ )} +
+
+ ))} +
+ + ) +} diff --git a/src/components/observer/reports/filtering-report-tabs.tsx b/src/components/observer/reports/filtering-report-tabs.tsx new file mode 100644 index 0000000..ae41a32 --- /dev/null +++ b/src/components/observer/reports/filtering-report-tabs.tsx @@ -0,0 +1,231 @@ +'use client' + +import { useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Button } from '@/components/ui/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { RoundTypeStatsCards } from '@/components/observer/round-type-stats' +import { FilteringScreeningBar } from './filtering-screening-bar' + +interface FilteringReportTabsProps { + roundId: string + programId: string +} + +type OutcomeFilter = 'ALL' | 'PASSED' | 'FILTERED_OUT' | 'FLAGGED' + +function outcomeBadge(outcome: string) { + switch (outcome) { + case 'PASSED': + return Passed + case 'FILTERED_OUT': + return Filtered Out + case 'FLAGGED': + return Flagged + default: + return {outcome} + } +} + +function ProjectsTab({ roundId }: { roundId: string }) { + const [outcomeFilter, setOutcomeFilter] = useState('ALL') + const [page, setPage] = useState(1) + const perPage = 20 + + const { data, isLoading } = trpc.analytics.getFilteringResults.useQuery({ + roundId, + outcome: outcomeFilter === 'ALL' ? undefined : outcomeFilter, + page, + perPage, + }) + + function handleOutcomeChange(value: string) { + setOutcomeFilter(value as OutcomeFilter) + setPage(1) + } + + return ( +
+
+ + {data && ( +

+ {data.total} result{data.total !== 1 ? 's' : ''} +

+ )} +
+ + {isLoading ? ( + + ) : data?.results.length ? ( + <> + {/* Desktop table */} +
+ + + + Project Title + Team + Category + Country + Outcome + Award Routing + + + + {data.results.map((r) => { + const effectiveOutcome = r.finalOutcome ?? r.outcome + const awardRouting = r.project.awardEligibilities + .map((ae) => ae.award.name) + .join(', ') + return ( + + {r.project.title} + {r.project.teamName} + + {r.project.competitionCategory ?? '—'} + + + {r.project.country ?? '—'} + + {outcomeBadge(effectiveOutcome)} + + {awardRouting || '—'} + + + ) + })} + +
+
+ + {/* Mobile card stack */} +
+ {data.results.map((r) => { + const effectiveOutcome = r.finalOutcome ?? r.outcome + const awardRouting = r.project.awardEligibilities + .map((ae) => ae.award.name) + .join(', ') + return ( + + +
+

{r.project.title}

+ {outcomeBadge(effectiveOutcome)} +
+

{r.project.teamName}

+
+ {r.project.competitionCategory && {r.project.competitionCategory}} + {r.project.country && {r.project.country}} +
+ {awardRouting && ( +

Award: {awardRouting}

+ )} +
+
+ ) + })} +
+ + {/* Pagination */} + {data.totalPages > 1 && ( +
+ + {Array.from({ length: Math.min(data.totalPages, 7) }, (_, i) => { + const pageNum = i + 1 + return ( + + ) + })} + +
+ )} + + ) : ( + + +

No filtering results found

+
+
+ )} +
+ ) +} + +function ScreeningResultsTab({ roundId }: { roundId: string }) { + return ( +
+ + +
+ ) +} + +export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) { + return ( + + + Screening Results + Projects + + + + + + + + + + + ) +} diff --git a/src/components/observer/reports/filtering-screening-bar.tsx b/src/components/observer/reports/filtering-screening-bar.tsx new file mode 100644 index 0000000..7b05783 --- /dev/null +++ b/src/components/observer/reports/filtering-screening-bar.tsx @@ -0,0 +1,115 @@ +'use client' + +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { cn } from '@/lib/utils' + +const SEGMENTS = [ + { key: 'passed' as const, label: 'Passed', color: '#2d8659', bg: '#2d865915' }, + { key: 'filteredOut' as const, label: 'Filtered Out', color: '#de0f1e', bg: '#de0f1e15' }, + { key: 'flagged' as const, label: 'Flagged', color: '#d97706', bg: '#d9770615' }, +] + +interface FilteringScreeningBarProps { + roundId: string + className?: string +} + +export function FilteringScreeningBar({ roundId, className }: FilteringScreeningBarProps) { + const { data, isLoading } = trpc.analytics.getFilteringResultStats.useQuery( + { roundId }, + { enabled: !!roundId } + ) + + return ( + + + Screening Results + + + {isLoading ? ( + <> + +
+ + + +
+ + ) : !data || data.total === 0 ? ( +

No screening data available.

+ ) : ( + <> + {/* Segmented bar */} +
+ {SEGMENTS.map(({ key, color }) => { + const pct = (data[key] / data.total) * 100 + if (pct === 0) return null + return ( +
+ ) + })} +
+ + {/* Stat pills */} +
+ {SEGMENTS.map(({ key, label, color, bg }) => { + const count = data[key] + const pct = Math.round((count / data.total) * 100) + return ( +
+ + {label} + {count} + ({pct}%) +
+ ) + })} + + {/* Total */} +
+ Total + {data.total} +
+ + {/* Overridden — only if any */} + {data.overridden > 0 && ( +
+ Overridden + {data.overridden} +
+ )} + + {/* Routed to awards — only if any */} + {data.routedToAwards > 0 && ( +
+ Routed to Awards + {data.routedToAwards} +
+ )} +
+ + )} + + + ) +} diff --git a/src/components/observer/reports/global-analytics-tab.tsx b/src/components/observer/reports/global-analytics-tab.tsx new file mode 100644 index 0000000..2ad2e30 --- /dev/null +++ b/src/components/observer/reports/global-analytics-tab.tsx @@ -0,0 +1,69 @@ +'use client' + +import { trpc } from '@/lib/trpc/client' +import { Skeleton } from '@/components/ui/skeleton' +import { + GeographicDistribution, + StatusBreakdownChart, + DiversityMetricsChart, + CrossStageComparisonChart, +} from '@/components/charts' + +interface GlobalAnalyticsTabProps { + programId: string + roundIds?: string[] +} + +export function GlobalAnalyticsTab({ programId, roundIds }: GlobalAnalyticsTabProps) { + const { data: geoData, isLoading: geoLoading } = + trpc.analytics.getGeographicDistribution.useQuery({ programId }) + + const { data: diversity, isLoading: diversityLoading } = + trpc.analytics.getDiversityMetrics.useQuery({ programId }) + + const { data: statusBreakdown, isLoading: statusLoading } = + trpc.analytics.getStatusBreakdown.useQuery({ programId }) + + const { data: crossRound, isLoading: crossLoading } = + trpc.analytics.getCrossRoundComparison.useQuery( + { roundIds: roundIds ?? [] }, + { enabled: !!roundIds && roundIds.length >= 2 } + ) + + return ( +
+ {/* Geographic Distribution */} + {geoLoading ? ( + + ) : geoData?.length ? ( + + ) : null} + + {/* Status and Diversity side by side */} +
+ {statusLoading ? ( + + ) : statusBreakdown ? ( + + ) : null} + + {diversityLoading ? ( + + ) : diversity ? ( + + ) : null} +
+ + {/* Cross-Round Comparison */} + {roundIds && roundIds.length >= 2 && ( + <> + {crossLoading ? ( + + ) : crossRound ? ( + + ) : null} + + )} +
+ ) +} diff --git a/src/components/observer/reports/intake-report-tabs.tsx b/src/components/observer/reports/intake-report-tabs.tsx new file mode 100644 index 0000000..de32b90 --- /dev/null +++ b/src/components/observer/reports/intake-report-tabs.tsx @@ -0,0 +1,37 @@ +'use client' + +import { trpc } from '@/lib/trpc/client' +import { Skeleton } from '@/components/ui/skeleton' +import { StatusBreakdownChart, DiversityMetricsChart } from '@/components/charts' +import { RoundTypeStatsCards } from '@/components/observer/round-type-stats' + +interface IntakeReportTabsProps { + roundId: string + programId: string +} + +export function IntakeReportTabs({ roundId, programId }: IntakeReportTabsProps) { + const { data: statusBreakdown, isLoading: statusLoading } = + trpc.analytics.getStatusBreakdown.useQuery({ roundId }) + + const { data: diversity, isLoading: diversityLoading } = + trpc.analytics.getDiversityMetrics.useQuery({ roundId }) + + return ( +
+ + + {statusLoading ? ( + + ) : statusBreakdown ? ( + + ) : null} + + {diversityLoading ? ( + + ) : diversity ? ( + + ) : null} +
+ ) +} diff --git a/src/components/observer/reports/live-final-report-tabs.tsx b/src/components/observer/reports/live-final-report-tabs.tsx new file mode 100644 index 0000000..618bfba --- /dev/null +++ b/src/components/observer/reports/live-final-report-tabs.tsx @@ -0,0 +1,29 @@ +'use client' + +import { trpc } from '@/lib/trpc/client' +import { Skeleton } from '@/components/ui/skeleton' +import { StatusBreakdownChart } from '@/components/charts' +import { RoundTypeStatsCards } from '@/components/observer/round-type-stats' + +interface LiveFinalReportTabsProps { + roundId: string + programId: string +} + +function StatusBreakdownSection({ roundId }: { roundId: string }) { + const { data: statusBreakdown, isLoading } = + trpc.analytics.getStatusBreakdown.useQuery({ roundId }) + + if (isLoading) return + if (!statusBreakdown) return null + return +} + +export function LiveFinalReportTabs({ roundId }: LiveFinalReportTabsProps) { + return ( +
+ + +
+ ) +} diff --git a/src/components/observer/reports/mentoring-report-tabs.tsx b/src/components/observer/reports/mentoring-report-tabs.tsx new file mode 100644 index 0000000..4e4c275 --- /dev/null +++ b/src/components/observer/reports/mentoring-report-tabs.tsx @@ -0,0 +1,29 @@ +'use client' + +import { trpc } from '@/lib/trpc/client' +import { Skeleton } from '@/components/ui/skeleton' +import { StatusBreakdownChart } from '@/components/charts' +import { RoundTypeStatsCards } from '@/components/observer/round-type-stats' + +interface MentoringReportTabsProps { + roundId: string + programId: string +} + +function StatusBreakdownSection({ roundId }: { roundId: string }) { + const { data: statusBreakdown, isLoading } = + trpc.analytics.getStatusBreakdown.useQuery({ roundId }) + + if (isLoading) return + if (!statusBreakdown) return null + return +} + +export function MentoringReportTabs({ roundId }: MentoringReportTabsProps) { + return ( +
+ + +
+ ) +} diff --git a/src/components/observer/reports/submission-report-tabs.tsx b/src/components/observer/reports/submission-report-tabs.tsx new file mode 100644 index 0000000..ecde349 --- /dev/null +++ b/src/components/observer/reports/submission-report-tabs.tsx @@ -0,0 +1,29 @@ +'use client' + +import { trpc } from '@/lib/trpc/client' +import { Skeleton } from '@/components/ui/skeleton' +import { StatusBreakdownChart } from '@/components/charts' +import { RoundTypeStatsCards } from '@/components/observer/round-type-stats' + +interface SubmissionReportTabsProps { + roundId: string + programId: string +} + +function StatusBreakdownSection({ roundId }: { roundId: string }) { + const { data: statusBreakdown, isLoading } = + trpc.analytics.getStatusBreakdown.useQuery({ roundId }) + + if (isLoading) return + if (!statusBreakdown) return null + return +} + +export function SubmissionReportTabs({ roundId }: SubmissionReportTabsProps) { + return ( +
+ + +
+ ) +} diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index 8c76e93..14e4950 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { router, observerProcedure } from '../trpc' import { normalizeCountryToCode } from '@/lib/countries' import { getUserAvatarUrl } from '../utils/avatar-url' +import { aggregateVotes } from '../services/deliberation' const editionOrRoundInput = z.object({ roundId: z.string().optional(), @@ -1456,4 +1457,231 @@ export const analyticsRouter = router({ createdAt: entry.createdAt, })) }), + + // ========================================================================= + // Round-Type-Specific Observer Reports + // ========================================================================= + + /** + * Get filtering result stats for a round (observer proxy of filtering.getResultStats) + */ + getFilteringResultStats: observerProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + const [passed, filteredOut, flagged, overridden] = await Promise.all([ + ctx.prisma.filteringResult.count({ + where: { + roundId: input.roundId, + OR: [ + { finalOutcome: 'PASSED' }, + { finalOutcome: null, outcome: 'PASSED' }, + ], + }, + }), + ctx.prisma.filteringResult.count({ + where: { + roundId: input.roundId, + OR: [ + { finalOutcome: 'FILTERED_OUT' }, + { finalOutcome: null, outcome: 'FILTERED_OUT' }, + ], + }, + }), + ctx.prisma.filteringResult.count({ + where: { + roundId: input.roundId, + OR: [ + { finalOutcome: 'FLAGGED' }, + { finalOutcome: null, outcome: 'FLAGGED' }, + ], + }, + }), + ctx.prisma.filteringResult.count({ + where: { roundId: input.roundId, overriddenBy: { not: null } }, + }), + ]) + + const round = await ctx.prisma.round.findUnique({ + where: { id: input.roundId }, + select: { competitionId: true }, + }) + + let routedToAwards = 0 + if (round?.competitionId) { + routedToAwards = await ctx.prisma.awardEligibility.count({ + where: { + award: { + competitionId: round.competitionId, + eligibilityMode: 'SEPARATE_POOL', + }, + shortlisted: true, + confirmedAt: { not: null }, + }, + }) + } + + return { passed, filteredOut, flagged, overridden, routedToAwards, total: passed + filteredOut + flagged } + }), + + /** + * Get filtering results list for a round (observer proxy of filtering.getResults) + */ + getFilteringResults: observerProcedure + .input(z.object({ + roundId: z.string(), + outcome: z.enum(['PASSED', 'FILTERED_OUT', 'FLAGGED']).optional(), + page: z.number().int().min(1).default(1), + perPage: z.number().int().min(1).max(100).default(20), + })) + .query(async ({ ctx, input }) => { + const { roundId, outcome, page, perPage } = input + const skip = (page - 1) * perPage + + const where: Record = { roundId } + if (outcome) { + where.OR = [ + { finalOutcome: outcome }, + { finalOutcome: null, outcome }, + ] + } + + const [results, total] = await Promise.all([ + ctx.prisma.filteringResult.findMany({ + where, + skip, + take: perPage, + orderBy: { createdAt: 'desc' }, + include: { + project: { + select: { + id: true, + title: true, + teamName: true, + competitionCategory: true, + country: true, + awardEligibilities: { + where: { + shortlisted: true, + confirmedAt: { not: null }, + award: { eligibilityMode: 'SEPARATE_POOL' }, + }, + select: { + award: { select: { name: true } }, + }, + }, + }, + }, + }, + }), + ctx.prisma.filteringResult.count({ where }), + ]) + + return { + results, + total, + page, + perPage, + totalPages: Math.ceil(total / perPage), + } + }), + + /** + * Get deliberation sessions for a round + */ + getDeliberationSessions: observerProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + const sessions = await ctx.prisma.deliberationSession.findMany({ + where: { roundId: input.roundId }, + select: { + id: true, + category: true, + status: true, + mode: true, + _count: { select: { votes: true, participants: true } }, + }, + orderBy: { createdAt: 'desc' }, + }) + return sessions + }), + + /** + * Get aggregated vote results for a deliberation session + */ + getDeliberationAggregate: observerProcedure + .input(z.object({ sessionId: z.string() })) + .query(async ({ ctx, input }) => { + const agg = await aggregateVotes(input.sessionId, ctx.prisma) + + const projectIds = agg.rankings.map((r) => r.projectId) + const projects = await ctx.prisma.project.findMany({ + where: { id: { in: projectIds } }, + select: { id: true, title: true, teamName: true }, + }) + const projectMap = new Map(projects.map((p) => [p.id, p])) + + return { + rankings: agg.rankings.map((r) => ({ + ...r, + projectTitle: projectMap.get(r.projectId)?.title ?? 'Unknown', + teamName: projectMap.get(r.projectId)?.teamName ?? '', + })), + hasTies: agg.hasTies, + tiedProjectIds: agg.tiedProjectIds, + } + }), + + /** + * Get juror score matrix for a round (capped at 30 most-assigned projects) + */ + getJurorScoreMatrix: observerProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + const assignments = await ctx.prisma.assignment.findMany({ + where: { roundId: input.roundId }, + include: { + user: { select: { id: true, name: true } }, + project: { select: { id: true, title: true } }, + evaluation: { + select: { globalScore: true, status: true }, + }, + }, + }) + + const jurorMap = new Map() + const projectMap = new Map() + const cells: { jurorId: string; projectId: string; score: number | null }[] = [] + + for (const a of assignments) { + jurorMap.set(a.user.id, a.user.name ?? 'Unknown') + projectMap.set(a.project.id, a.project.title) + + if (a.evaluation?.status === 'SUBMITTED') { + cells.push({ + jurorId: a.user.id, + projectId: a.project.id, + score: a.evaluation.globalScore, + }) + } + } + + const projectAssignCounts = new Map() + for (const a of assignments) { + projectAssignCounts.set(a.project.id, (projectAssignCounts.get(a.project.id) ?? 0) + 1) + } + const topProjectIds = [...projectAssignCounts.entries()] + .sort(([, a], [, b]) => b - a) + .slice(0, 30) + .map(([id]) => id) + + const topProjectSet = new Set(topProjectIds) + + return { + jurors: [...jurorMap.entries()].map(([id, name]) => ({ id, name })), + projects: topProjectIds.map((id) => ({ id, title: projectMap.get(id) ?? 'Unknown' })), + cells: cells.filter((c) => topProjectSet.has(c.projectId)), + truncated: projectAssignCounts.size > 30, + totalProjects: projectAssignCounts.size, + } + }), })