Compare commits
2 Commits
6e697cb5d8
...
4f73ba5a0e
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f73ba5a0e | |||
| 26e8830df2 |
@@ -45,6 +45,7 @@ import {
|
|||||||
StatusBreakdownChart,
|
StatusBreakdownChart,
|
||||||
CriteriaScoresChart,
|
CriteriaScoresChart,
|
||||||
} from '@/components/charts'
|
} from '@/components/charts'
|
||||||
|
import { BarChart } from '@tremor/react'
|
||||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||||
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
@@ -605,7 +606,7 @@ function JurorsTab({ selectedValue }: { selectedValue: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScoresTab({ selectedValue }: { selectedValue: string }) {
|
function ScoresTab({ selectedValue, programId }: { selectedValue: string; programId: string | undefined }) {
|
||||||
const queryInput = parseSelection(selectedValue)
|
const queryInput = parseSelection(selectedValue)
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||||
|
|
||||||
@@ -618,8 +619,12 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
|
|||||||
const { data: criteriaScores, isLoading: criteriaLoading } =
|
const { data: criteriaScores, isLoading: criteriaLoading } =
|
||||||
trpc.analytics.getCriteriaScores.useQuery(queryInput, { enabled: hasSelection })
|
trpc.analytics.getCriteriaScores.useQuery(queryInput, { enabled: hasSelection })
|
||||||
|
|
||||||
const { data: overviewStats } =
|
const geoProgramId = queryInput.programId || programId
|
||||||
trpc.analytics.getOverviewStats.useQuery(queryInput, { enabled: hasSelection })
|
const { data: geoData, isLoading: geoLoading } =
|
||||||
|
trpc.analytics.getGeographicDistribution.useQuery(
|
||||||
|
{ programId: geoProgramId!, roundId: queryInput.roundId },
|
||||||
|
{ enabled: !!geoProgramId }
|
||||||
|
)
|
||||||
|
|
||||||
const [csvOpen, setCsvOpen] = useState(false)
|
const [csvOpen, setCsvOpen] = useState(false)
|
||||||
const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>()
|
const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>()
|
||||||
@@ -641,55 +646,18 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
|
|||||||
return result
|
return result
|
||||||
}, [criteriaScores])
|
}, [criteriaScores])
|
||||||
|
|
||||||
// Derived scoring insights
|
// Country chart data
|
||||||
const scoringInsights = (() => {
|
const countryChartData = (() => {
|
||||||
if (!scoreDistribution?.distribution?.length) return null
|
if (!geoData?.length) return []
|
||||||
const dist = scoreDistribution.distribution
|
const sorted = [...geoData].sort((a, b) => b.count - a.count)
|
||||||
const totalScores = dist.reduce((sum, d) => sum + d.count, 0)
|
return sorted.slice(0, 15).map((d) => {
|
||||||
if (totalScores === 0) return null
|
let name = d.countryCode
|
||||||
|
try {
|
||||||
// Find score with highest count
|
const displayNames = new Intl.DisplayNames(['en'], { type: 'region' })
|
||||||
const peakBucket = dist.reduce((a, b) => (b.count > a.count ? b : a), dist[0])
|
name = displayNames.of(d.countryCode.toUpperCase()) || d.countryCode
|
||||||
// Find highest and lowest scores that have counts
|
} catch { /* keep code */ }
|
||||||
const scoredBuckets = dist.filter(d => d.count > 0)
|
return { country: name, Projects: d.count }
|
||||||
const highScore = scoredBuckets.length > 0 ? Math.max(...scoredBuckets.map(d => d.score)) : 0
|
})
|
||||||
const lowScore = scoredBuckets.length > 0 ? Math.min(...scoredBuckets.map(d => d.score)) : 0
|
|
||||||
// Median: walk through distribution to find the middle
|
|
||||||
let cumulative = 0
|
|
||||||
let median = 0
|
|
||||||
for (const d of dist) {
|
|
||||||
cumulative += d.count
|
|
||||||
if (cumulative >= totalScores / 2) {
|
|
||||||
median = d.score
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Scores ≥ 7 count
|
|
||||||
const highScoreCount = dist.filter(d => d.score >= 7).reduce((sum, d) => sum + d.count, 0)
|
|
||||||
const highScorePct = Math.round((highScoreCount / totalScores) * 100)
|
|
||||||
|
|
||||||
return { peakScore: peakBucket.score, highScore, lowScore, median, totalScores, highScorePct }
|
|
||||||
})()
|
|
||||||
|
|
||||||
// Criteria insights
|
|
||||||
const criteriaInsights = (() => {
|
|
||||||
if (!criteriaScores?.length) return null
|
|
||||||
const sorted = [...criteriaScores].sort((a, b) => b.averageScore - a.averageScore)
|
|
||||||
return {
|
|
||||||
strongest: sorted[0],
|
|
||||||
weakest: sorted[sorted.length - 1],
|
|
||||||
spread: sorted[0].averageScore - sorted[sorted.length - 1].averageScore,
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
// Status insights
|
|
||||||
const statusInsights = (() => {
|
|
||||||
if (!statusBreakdown?.length) return null
|
|
||||||
const total = statusBreakdown.reduce((sum, s) => sum + s.count, 0)
|
|
||||||
const reviewed = statusBreakdown
|
|
||||||
.filter(s => !['SUBMITTED', 'ELIGIBLE', 'DRAFT'].includes(s.status))
|
|
||||||
.reduce((sum, s) => sum + s.count, 0)
|
|
||||||
return { total, reviewed, reviewedPct: total > 0 ? Math.round((reviewed / total) * 100) : 0 }
|
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -697,7 +665,7 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
|
|||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold">Scores & Analytics</h2>
|
<h2 className="text-base font-semibold">Scores & Analytics</h2>
|
||||||
<p className="text-sm text-muted-foreground">Score distributions, criteria breakdown and insights</p>
|
<p className="text-sm text-muted-foreground">Score distributions, criteria breakdown and geographic data</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -719,48 +687,6 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
|
|||||||
onRequestData={handleRequestCsvData}
|
onRequestData={handleRequestCsvData}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Scoring Insight Tiles */}
|
|
||||||
{hasSelection && scoringInsights && (
|
|
||||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
|
||||||
<AnimatedCard index={0}>
|
|
||||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Median Score</p>
|
|
||||||
<p className="text-2xl font-bold mt-1 tabular-nums">{scoringInsights.median}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">of 10</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
<AnimatedCard index={1}>
|
|
||||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Score Range</p>
|
|
||||||
<p className="text-2xl font-bold mt-1 tabular-nums">{scoringInsights.lowScore}–{scoringInsights.highScore}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">lowest to highest</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
<AnimatedCard index={2}>
|
|
||||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Most Common</p>
|
|
||||||
<p className="text-2xl font-bold mt-1 tabular-nums">{scoringInsights.peakScore}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">peak score</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
<AnimatedCard index={3}>
|
|
||||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">High Scores (7+)</p>
|
|
||||||
<p className="text-2xl font-bold mt-1 tabular-nums">{scoringInsights.highScorePct}%</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">of {scoringInsights.totalScores} scores</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Score Distribution & Status Breakdown */}
|
{/* Score Distribution & Status Breakdown */}
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
{scoreLoading ? (
|
{scoreLoading ? (
|
||||||
@@ -805,85 +731,33 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Criteria & Coverage Insights */}
|
{/* Country Distribution */}
|
||||||
{hasSelection && (criteriaInsights || statusInsights || overviewStats) && (
|
{geoLoading ? (
|
||||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
<Skeleton className="h-[400px]" />
|
||||||
{criteriaInsights && (
|
) : countryChartData.length > 0 ? (
|
||||||
<>
|
<Card>
|
||||||
<AnimatedCard index={4}>
|
<CardHeader>
|
||||||
<Card className="border-l-4 border-l-emerald-500">
|
<CardTitle className="flex items-center justify-between">
|
||||||
<CardContent className="p-4">
|
<span>Top Countries</span>
|
||||||
<p className="text-xs font-medium text-muted-foreground">Strongest Criterion</p>
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
<p className="font-semibold mt-1 leading-tight">{criteriaInsights.strongest.name}</p>
|
{geoData?.length ?? 0} countries represented
|
||||||
<p className="text-lg font-bold tabular-nums text-emerald-600 mt-1">
|
</span>
|
||||||
{criteriaInsights.strongest.averageScore.toFixed(2)}
|
</CardTitle>
|
||||||
</p>
|
</CardHeader>
|
||||||
</CardContent>
|
<CardContent>
|
||||||
</Card>
|
<BarChart
|
||||||
</AnimatedCard>
|
data={countryChartData}
|
||||||
<AnimatedCard index={5}>
|
index="country"
|
||||||
<Card className="border-l-4 border-l-amber-500">
|
categories={['Projects']}
|
||||||
<CardContent className="p-4">
|
colors={['blue']}
|
||||||
<p className="text-xs font-medium text-muted-foreground">Weakest Criterion</p>
|
layout="vertical"
|
||||||
<p className="font-semibold mt-1 leading-tight">{criteriaInsights.weakest.name}</p>
|
yAxisWidth={140}
|
||||||
<p className="text-lg font-bold tabular-nums text-amber-600 mt-1">
|
showLegend={false}
|
||||||
{criteriaInsights.weakest.averageScore.toFixed(2)}
|
className="h-[400px]"
|
||||||
</p>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
) : null}
|
||||||
<AnimatedCard index={6}>
|
|
||||||
<Card className="border-l-4 border-l-blue-500">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Criteria Spread</p>
|
|
||||||
<p className="text-2xl font-bold tabular-nums mt-1">{criteriaInsights.spread.toFixed(2)}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">points between strongest and weakest</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{overviewStats && (
|
|
||||||
<>
|
|
||||||
<AnimatedCard index={7}>
|
|
||||||
<Card className="border-l-4 border-l-violet-500">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Total Evaluations</p>
|
|
||||||
<p className="text-2xl font-bold tabular-nums mt-1">{overviewStats.evaluationCount}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{overviewStats.projectCount > 0
|
|
||||||
? `~${(overviewStats.evaluationCount / overviewStats.projectCount).toFixed(1)} per project`
|
|
||||||
: 'submitted'}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
<AnimatedCard index={8}>
|
|
||||||
<Card className="border-l-4 border-l-teal-500">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Completion Rate</p>
|
|
||||||
<p className="text-2xl font-bold tabular-nums mt-1">{overviewStats.completionRate}%</p>
|
|
||||||
<Progress value={overviewStats.completionRate} className="mt-2 h-1.5" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{statusInsights && (
|
|
||||||
<AnimatedCard index={9}>
|
|
||||||
<Card className="border-l-4 border-l-cyan-500">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Review Progress</p>
|
|
||||||
<p className="text-2xl font-bold tabular-nums mt-1">{statusInsights.reviewed}/{statusInsights.total}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{statusInsights.reviewedPct}% of projects reviewed
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -989,7 +863,7 @@ function ReportsPageContent() {
|
|||||||
|
|
||||||
<TabsContent value="scores">
|
<TabsContent value="scores">
|
||||||
{selectedValue ? (
|
{selectedValue ? (
|
||||||
<ScoresTab selectedValue={selectedValue} />
|
<ScoresTab selectedValue={selectedValue} programId={selectedRound?.programId ?? programs?.[0]?.id} />
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
|||||||
@@ -313,3 +313,27 @@ div[class*="recharts-tooltip"] {
|
|||||||
background-color: hsl(var(--card)) !important;
|
background-color: hsl(var(--card)) !important;
|
||||||
border-color: hsl(var(--border)) !important;
|
border-color: hsl(var(--border)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tremor/Recharts tooltip color indicator icons — fix rendering */
|
||||||
|
.recharts-tooltip-wrapper svg.recharts-surface {
|
||||||
|
display: inline-block !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tremor custom tooltip color dots */
|
||||||
|
[class*="tremor"] [role="tooltip"] span[class*="bg-"],
|
||||||
|
[class*="tremor"] [role="tooltip"] span[style*="background"] {
|
||||||
|
border-radius: 2px !important;
|
||||||
|
min-width: 10px !important;
|
||||||
|
min-height: 10px !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recharts default tooltip icon fix — ensure SVG paths have correct fill */
|
||||||
|
.recharts-default-tooltip .recharts-tooltip-item-list {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-default-tooltip .recharts-tooltip-item svg {
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,31 +20,38 @@ export const BRAND_COLORS = [
|
|||||||
|
|
||||||
// Tremor named colors for chart components
|
// Tremor named colors for chart components
|
||||||
// These are the official Tremor palette names that render correctly
|
// These are the official Tremor palette names that render correctly
|
||||||
export const TREMOR_BRAND = 'cyan' as const
|
export const TREMOR_BRAND = 'blue' as const
|
||||||
export const TREMOR_ACCENT = 'teal' as const
|
export const TREMOR_ACCENT = 'indigo' as const
|
||||||
export const TREMOR_CHART_COLORS = [
|
export const TREMOR_CHART_COLORS = [
|
||||||
'cyan',
|
|
||||||
'teal',
|
|
||||||
'blue',
|
'blue',
|
||||||
'emerald',
|
'emerald',
|
||||||
'amber',
|
'amber',
|
||||||
'violet',
|
'violet',
|
||||||
'rose',
|
'rose',
|
||||||
'indigo',
|
'indigo',
|
||||||
'lime',
|
'sky',
|
||||||
'fuchsia',
|
'fuchsia',
|
||||||
|
'lime',
|
||||||
|
'orange',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
// Donut / status chart colors (mapped to Tremor names)
|
// Donut / status chart colors (mapped to Tremor names)
|
||||||
|
// Covers both global ProjectStatus and round-level ProjectRoundState values
|
||||||
export const TREMOR_STATUS_COLORS: Record<string, string> = {
|
export const TREMOR_STATUS_COLORS: Record<string, string> = {
|
||||||
SUBMITTED: 'slate',
|
// Global project statuses
|
||||||
ELIGIBLE: 'cyan',
|
SUBMITTED: 'sky',
|
||||||
|
ELIGIBLE: 'blue',
|
||||||
ASSIGNED: 'violet',
|
ASSIGNED: 'violet',
|
||||||
SEMIFINALIST: 'amber',
|
SEMIFINALIST: 'amber',
|
||||||
FINALIST: 'emerald',
|
FINALIST: 'emerald',
|
||||||
REJECTED: 'rose',
|
REJECTED: 'rose',
|
||||||
DRAFT: 'gray',
|
DRAFT: 'gray',
|
||||||
WITHDRAWN: 'neutral',
|
WITHDRAWN: 'slate',
|
||||||
|
// Round-level states (ProjectRoundState)
|
||||||
|
PENDING: 'sky',
|
||||||
|
IN_PROGRESS: 'blue',
|
||||||
|
PASSED: 'emerald',
|
||||||
|
COMPLETED: 'indigo',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project status colors — mapped to actual ProjectStatus enum values
|
// Project status colors — mapped to actual ProjectStatus enum values
|
||||||
@@ -69,6 +76,11 @@ export const STATUS_LABELS: Record<string, string> = {
|
|||||||
REJECTED: 'Rejected',
|
REJECTED: 'Rejected',
|
||||||
DRAFT: 'Draft',
|
DRAFT: 'Draft',
|
||||||
WITHDRAWN: 'Withdrawn',
|
WITHDRAWN: 'Withdrawn',
|
||||||
|
// Round-level states
|
||||||
|
PENDING: 'Pending',
|
||||||
|
IN_PROGRESS: 'In Progress',
|
||||||
|
PASSED: 'Passed',
|
||||||
|
COMPLETED: 'Completed',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
|||||||
data={chartData}
|
data={chartData}
|
||||||
index="criterion"
|
index="criterion"
|
||||||
categories={['Avg Score']}
|
categories={['Avg Score']}
|
||||||
colors={['teal']}
|
colors={['indigo']}
|
||||||
maxValue={10}
|
maxValue={10}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
yAxisWidth={160}
|
yAxisWidth={160}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function CrossStageComparisonChart({
|
|||||||
data={baseData}
|
data={baseData}
|
||||||
index="name"
|
index="name"
|
||||||
categories={['Projects']}
|
categories={['Projects']}
|
||||||
colors={['cyan']}
|
colors={['blue']}
|
||||||
showLegend={false}
|
showLegend={false}
|
||||||
yAxisWidth={40}
|
yAxisWidth={40}
|
||||||
className="h-[200px]"
|
className="h-[200px]"
|
||||||
@@ -75,7 +75,7 @@ export function CrossStageComparisonChart({
|
|||||||
data={baseData}
|
data={baseData}
|
||||||
index="name"
|
index="name"
|
||||||
categories={['Evaluations']}
|
categories={['Evaluations']}
|
||||||
colors={['teal']}
|
colors={['violet']}
|
||||||
showLegend={false}
|
showLegend={false}
|
||||||
yAxisWidth={40}
|
yAxisWidth={40}
|
||||||
className="h-[200px]"
|
className="h-[200px]"
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
|||||||
data={categoryData}
|
data={categoryData}
|
||||||
index="category"
|
index="category"
|
||||||
categories={['Count']}
|
categories={['Count']}
|
||||||
colors={['cyan']}
|
colors={['indigo']}
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
yAxisWidth={120}
|
yAxisWidth={120}
|
||||||
showLegend={false}
|
showLegend={false}
|
||||||
@@ -160,7 +160,7 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
|||||||
data={oceanIssueData}
|
data={oceanIssueData}
|
||||||
index="issue"
|
index="issue"
|
||||||
categories={['Count']}
|
categories={['Count']}
|
||||||
colors={['teal']}
|
colors={['blue']}
|
||||||
showLegend={false}
|
showLegend={false}
|
||||||
yAxisWidth={40}
|
yAxisWidth={40}
|
||||||
className="h-[400px]"
|
className="h-[400px]"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
|||||||
data={chartData}
|
data={chartData}
|
||||||
index="date"
|
index="date"
|
||||||
categories={['Cumulative', 'Daily']}
|
categories={['Cumulative', 'Daily']}
|
||||||
colors={['cyan', 'teal']}
|
colors={['indigo', 'amber']}
|
||||||
curveType="monotone"
|
curveType="monotone"
|
||||||
showGradient={true}
|
showGradient={true}
|
||||||
yAxisWidth={50}
|
yAxisWidth={50}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
|||||||
y="Std Deviation"
|
y="Std Deviation"
|
||||||
category="category"
|
category="category"
|
||||||
size="size"
|
size="size"
|
||||||
colors={['cyan', 'rose']}
|
colors={['blue', 'rose']}
|
||||||
className="h-[400px]"
|
className="h-[400px]"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
|||||||
data={chartData}
|
data={chartData}
|
||||||
index="juror"
|
index="juror"
|
||||||
categories={['Completed', 'Remaining']}
|
categories={['Completed', 'Remaining']}
|
||||||
colors={['cyan', 'gray']}
|
colors={['blue', 'slate']}
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
stack={true}
|
stack={true}
|
||||||
yAxisWidth={160}
|
yAxisWidth={160}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function ProjectRankingsChart({
|
|||||||
data={chartData}
|
data={chartData}
|
||||||
index="project"
|
index="project"
|
||||||
categories={['Score']}
|
categories={['Score']}
|
||||||
colors={['teal']}
|
colors={['blue']}
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
yAxisWidth={200}
|
yAxisWidth={200}
|
||||||
maxValue={10}
|
maxValue={10}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function ScoreDistributionChart({
|
|||||||
data={chartData}
|
data={chartData}
|
||||||
index="score"
|
index="score"
|
||||||
categories={['Count']}
|
categories={['Count']}
|
||||||
colors={['cyan']}
|
colors={['blue']}
|
||||||
yAxisWidth={40}
|
yAxisWidth={40}
|
||||||
showLegend={false}
|
showLegend={false}
|
||||||
className="h-[300px]"
|
className="h-[300px]"
|
||||||
|
|||||||
@@ -249,12 +249,24 @@ export const analyticsRouter = router({
|
|||||||
getStatusBreakdown: observerProcedure
|
getStatusBreakdown: observerProcedure
|
||||||
.input(editionOrRoundInput)
|
.input(editionOrRoundInput)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
if (input.roundId) {
|
||||||
|
// Round-level: use ProjectRoundState for accurate per-round breakdown
|
||||||
|
const states = await ctx.prisma.projectRoundState.groupBy({
|
||||||
|
by: ['state'],
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
_count: true,
|
||||||
|
})
|
||||||
|
return states.map((s) => ({
|
||||||
|
status: s.state,
|
||||||
|
count: s._count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
// Edition-level: use global project status
|
||||||
const projects = await ctx.prisma.project.groupBy({
|
const projects = await ctx.prisma.project.groupBy({
|
||||||
by: ['status'],
|
by: ['status'],
|
||||||
where: projectWhere(input),
|
where: projectWhere(input),
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return projects.map((p) => ({
|
return projects.map((p) => ({
|
||||||
status: p.status,
|
status: p.status,
|
||||||
count: p._count,
|
count: p._count,
|
||||||
@@ -327,12 +339,14 @@ export const analyticsRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build label → Set<id> map so program-level queries match all IDs for the same criterion label
|
// Build label → Set<id> map so program-level queries match all IDs for the same criterion label
|
||||||
|
// Skip boolean and section_header criteria — they don't have numeric scores
|
||||||
const labelToIds = new Map<string, Set<string>>()
|
const labelToIds = new Map<string, Set<string>>()
|
||||||
const labelToFirst = new Map<string, { id: string; label: string }>()
|
const labelToFirst = new Map<string, { id: string; label: string }>()
|
||||||
evaluationForms.forEach((form) => {
|
evaluationForms.forEach((form) => {
|
||||||
const criteria = form.criteriaJson as Array<{ id: string; label: string }> | null
|
const criteria = form.criteriaJson as Array<{ id: string; label: string; type?: string }> | null
|
||||||
if (criteria) {
|
if (criteria) {
|
||||||
criteria.forEach((c) => {
|
criteria.forEach((c) => {
|
||||||
|
if (c.type === 'boolean' || c.type === 'section_header') return
|
||||||
if (!labelToIds.has(c.label)) {
|
if (!labelToIds.has(c.label)) {
|
||||||
labelToIds.set(c.label, new Set())
|
labelToIds.set(c.label, new Set())
|
||||||
labelToFirst.set(c.label, c)
|
labelToFirst.set(c.label, c)
|
||||||
|
|||||||
Reference in New Issue
Block a user