Observer platform redesign Phase 4: migrate charts to Tremor, redesign all pages
Some checks failed
Build and Push Docker Image / build (push) Failing after 23s
Some checks failed
Build and Push Docker Image / build (push) Failing after 23s
- Migrate 9 chart components from Nivo to @tremor/react (BarChart, AreaChart, DonutChart, ScatterChart) - Remove @nivo/*, @react-spring/web dependencies (45 packages removed) - Redesign dashboard: 6 stat tiles, competition pipeline, score distribution, juror workload, activity feed - Add new /observer/projects page with search, filters, sorting, pagination, CSV export - Restructure reports page from 5 tabs to 3 (Progress, Jurors, Scores & Analytics) with per-tab CSV export - Redesign project detail: breadcrumb nav, score card header, 3-tab layout (Overview/Evaluations/Files) - Update loading skeletons to match new layouts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
998
package-lock.json
generated
998
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -31,11 +31,6 @@
|
|||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@mantine/core": "^8.3.13",
|
"@mantine/core": "^8.3.13",
|
||||||
"@mantine/hooks": "^8.3.13",
|
"@mantine/hooks": "^8.3.13",
|
||||||
"@nivo/bar": "^0.99.0",
|
|
||||||
"@nivo/core": "^0.99.0",
|
|
||||||
"@nivo/line": "^0.99.0",
|
|
||||||
"@nivo/pie": "^0.99.0",
|
|
||||||
"@nivo/scatterplot": "^0.99.0",
|
|
||||||
"@notionhq/client": "^2.3.0",
|
"@notionhq/client": "^2.3.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
@@ -57,9 +52,9 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"@react-spring/web": "^10.0.3",
|
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"@tremor/react": "^3.18.7",
|
||||||
"@trpc/client": "^11.0.0-rc.678",
|
"@trpc/client": "^11.0.0-rc.678",
|
||||||
"@trpc/react-query": "^11.0.0-rc.678",
|
"@trpc/react-query": "^11.0.0-rc.678",
|
||||||
"@trpc/server": "^11.0.0-rc.678",
|
"@trpc/server": "^11.0.0-rc.678",
|
||||||
|
|||||||
88
src/app/(observer)/observer/loading.tsx
Normal file
88
src/app/(observer)/observer/loading.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
|
|
||||||
|
export default function ObserverLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="mt-2 h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-10 w-[200px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 6 stat tiles */}
|
||||||
|
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-8 w-12" />
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pipeline */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-4 overflow-hidden">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-24 w-48 shrink-0 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 3-col middle row */}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-28" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-[200px] w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2-col bottom row */}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-28" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-3 w-3 rounded-full" />
|
||||||
|
<Skeleton className="h-4 flex-1" />
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
src/app/(observer)/observer/projects/loading.tsx
Normal file
37
src/app/(observer)/observer/projects/loading.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
|
|
||||||
|
export default function ObserverProjectsLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-36" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-9 w-28" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<Skeleton className="h-5 w-14" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
|
<Skeleton className="h-10 flex-1" />
|
||||||
|
<Skeleton className="h-10 w-full sm:w-[220px]" />
|
||||||
|
<Skeleton className="h-10 w-full sm:w-[180px]" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 space-y-2">
|
||||||
|
{[...Array(8)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
src/app/(observer)/observer/projects/page.tsx
Normal file
8
src/app/(observer)/observer/projects/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { ObserverProjectsContent } from '@/components/observer/observer-projects-content'
|
||||||
|
|
||||||
|
export const metadata = { title: 'Observer — Projects' }
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default function ObserverProjectsPage() {
|
||||||
|
return <ObserverProjectsContent />
|
||||||
|
}
|
||||||
57
src/app/(observer)/observer/reports/loading.tsx
Normal file
57
src/app/(observer)/observer/reports/loading.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
|
|
||||||
|
export default function ObserverReportsLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-8 w-32" />
|
||||||
|
<Skeleton className="mt-2 h-4 w-56" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Round selector */}
|
||||||
|
<Skeleton className="h-10 w-full sm:w-[300px]" />
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<Skeleton className="h-10 w-80" />
|
||||||
|
|
||||||
|
{/* 3 stat tiles */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="space-y-0 pb-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
<Skeleton className="mt-2 h-2 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart skeleton */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-[300px] w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Table skeleton */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-36" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,3 @@
|
|||||||
import type { PartialTheme } from '@nivo/theming'
|
|
||||||
|
|
||||||
// Brand colors from CLAUDE.md
|
// Brand colors from CLAUDE.md
|
||||||
export const BRAND_DARK_BLUE = '#053d57'
|
export const BRAND_DARK_BLUE = '#053d57'
|
||||||
export const BRAND_RED = '#de0f1e'
|
export const BRAND_RED = '#de0f1e'
|
||||||
@@ -73,50 +71,6 @@ function lerpColor(a: string, b: string, t: number): string {
|
|||||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${bl.toString(16).padStart(2, '0')}`
|
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${bl.toString(16).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared Nivo theme — brand fonts, clean grid, shadcn-style tooltips
|
|
||||||
*/
|
|
||||||
export const nivoTheme: PartialTheme = {
|
|
||||||
background: 'transparent',
|
|
||||||
text: {
|
|
||||||
fontSize: 12,
|
|
||||||
fill: '#374151',
|
|
||||||
fontFamily: 'Montserrat, system-ui, sans-serif',
|
|
||||||
},
|
|
||||||
axis: {
|
|
||||||
domain: {
|
|
||||||
line: { stroke: '#e5e7eb', strokeWidth: 1 },
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
line: { stroke: '#e5e7eb', strokeWidth: 1 },
|
|
||||||
text: { fontSize: 11, fill: '#6b7280' },
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
text: { fontSize: 13, fill: '#374151', fontWeight: 600 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
line: { stroke: '#f3f4f6', strokeWidth: 1 },
|
|
||||||
},
|
|
||||||
legends: {
|
|
||||||
text: { fontSize: 12, fill: '#374151' },
|
|
||||||
},
|
|
||||||
labels: {
|
|
||||||
text: { fontSize: 12, fill: '#374151', fontWeight: 500 },
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
container: {
|
|
||||||
background: '#ffffff',
|
|
||||||
color: '#1f2937',
|
|
||||||
fontSize: 12,
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: get color for a status value from STATUS_COLORS
|
* Helper: get color for a status value from STATUS_COLORS
|
||||||
* Falls back to a neutral gray
|
* Falls back to a neutral gray
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ResponsiveBar } from '@nivo/bar'
|
import { BarChart } from '@tremor/react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { nivoTheme, scoreGradient } from './chart-theme'
|
import { BRAND_TEAL } from './chart-theme'
|
||||||
|
|
||||||
interface CriteriaScoreData {
|
interface CriteriaScoreData {
|
||||||
id: string
|
id: string
|
||||||
@@ -15,13 +15,6 @@ interface CriteriaScoresProps {
|
|||||||
data: CriteriaScoreData[]
|
data: CriteriaScoreData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type CriterionBarDatum = {
|
|
||||||
criterion: string
|
|
||||||
averageScore: number
|
|
||||||
fullName: string
|
|
||||||
count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
||||||
if (!data?.length) return null
|
if (!data?.length) return null
|
||||||
|
|
||||||
@@ -30,12 +23,10 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
|||||||
? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length
|
? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
const chartData: CriterionBarDatum[] = data.map((d) => ({
|
const chartData = data.map((d) => ({
|
||||||
criterion:
|
criterion:
|
||||||
d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name,
|
d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name,
|
||||||
averageScore: d.averageScore,
|
'Avg Score': parseFloat(d.averageScore.toFixed(2)),
|
||||||
fullName: d.name,
|
|
||||||
count: d.count,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,55 +40,17 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div style={{ height: '300px' }}>
|
<BarChart
|
||||||
<ResponsiveBar
|
data={chartData}
|
||||||
data={chartData}
|
index="criterion"
|
||||||
keys={['averageScore']}
|
categories={['Avg Score']}
|
||||||
indexBy="criterion"
|
colors={[BRAND_TEAL] as string[]}
|
||||||
theme={nivoTheme}
|
maxValue={10}
|
||||||
colors={(bar) =>
|
yAxisWidth={40}
|
||||||
scoreGradient(bar.data.averageScore as number)
|
showLegend={false}
|
||||||
}
|
className="h-[300px]"
|
||||||
valueScale={{ type: 'linear', max: 10 }}
|
rotateLabelX={{ angle: -45, xAxisHeight: 60 }}
|
||||||
borderRadius={4}
|
/>
|
||||||
enableLabel={true}
|
|
||||||
label={(d) => {
|
|
||||||
const v = d.value
|
|
||||||
return v != null ? Number(v).toFixed(1) : ''
|
|
||||||
}}
|
|
||||||
labelSkipHeight={12}
|
|
||||||
labelTextColor="#ffffff"
|
|
||||||
axisBottom={{
|
|
||||||
tickRotation: -45,
|
|
||||||
}}
|
|
||||||
axisLeft={{
|
|
||||||
legend: 'Score',
|
|
||||||
legendPosition: 'middle',
|
|
||||||
legendOffset: -40,
|
|
||||||
}}
|
|
||||||
margin={{ top: 20, right: 20, bottom: 80, left: 50 }}
|
|
||||||
padding={0.25}
|
|
||||||
tooltip={({ data: rowData }) => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>{rowData.fullName}</strong>
|
|
||||||
<br />
|
|
||||||
Average Score: {Number(rowData.averageScore).toFixed(2)}
|
|
||||||
<br />
|
|
||||||
Ratings: {rowData.count}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
animate={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ResponsiveBar } from '@nivo/bar'
|
import { BarChart } from '@tremor/react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { nivoTheme, BRAND_COLORS } from './chart-theme'
|
import { BRAND_COLORS } from './chart-theme'
|
||||||
|
|
||||||
interface StageComparison {
|
interface StageComparison {
|
||||||
roundId: string
|
roundId: string
|
||||||
@@ -36,16 +36,14 @@ export function CrossStageComparisonChart({
|
|||||||
round.roundName.length > 20
|
round.roundName.length > 20
|
||||||
? round.roundName.slice(0, 20) + '...'
|
? round.roundName.slice(0, 20) + '...'
|
||||||
: round.roundName,
|
: round.roundName,
|
||||||
projects: round.projectCount,
|
Projects: round.projectCount,
|
||||||
evaluations: round.evaluationCount,
|
Evaluations: round.evaluationCount,
|
||||||
completionRate: round.completionRate,
|
'Completion Rate': round.completionRate,
|
||||||
avgScore: round.averageScore
|
'Avg Score': round.averageScore
|
||||||
? parseFloat(round.averageScore.toFixed(2))
|
? parseFloat(round.averageScore.toFixed(2))
|
||||||
: 0,
|
: 0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const sharedMargin = { top: 10, right: 10, bottom: 40, left: 40 }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -58,25 +56,16 @@ export function CrossStageComparisonChart({
|
|||||||
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div style={{ height: '200px' }}>
|
<BarChart
|
||||||
<ResponsiveBar
|
data={baseData}
|
||||||
data={baseData}
|
index="name"
|
||||||
keys={['projects']}
|
categories={['Projects']}
|
||||||
indexBy="name"
|
colors={[BRAND_COLORS[0]] as string[]}
|
||||||
theme={nivoTheme}
|
showLegend={false}
|
||||||
colors={[BRAND_COLORS[0]]}
|
yAxisWidth={40}
|
||||||
borderRadius={4}
|
className="h-[200px]"
|
||||||
enableLabel={true}
|
rotateLabelX={{ angle: -25, xAxisHeight: 40 }}
|
||||||
labelSkipHeight={12}
|
/>
|
||||||
labelTextColor="#ffffff"
|
|
||||||
margin={sharedMargin}
|
|
||||||
padding={0.3}
|
|
||||||
axisBottom={{
|
|
||||||
tickRotation: -25,
|
|
||||||
}}
|
|
||||||
animate={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -87,25 +76,16 @@ export function CrossStageComparisonChart({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div style={{ height: '200px' }}>
|
<BarChart
|
||||||
<ResponsiveBar
|
data={baseData}
|
||||||
data={baseData}
|
index="name"
|
||||||
keys={['evaluations']}
|
categories={['Evaluations']}
|
||||||
indexBy="name"
|
colors={[BRAND_COLORS[2]] as string[]}
|
||||||
theme={nivoTheme}
|
showLegend={false}
|
||||||
colors={[BRAND_COLORS[2]]}
|
yAxisWidth={40}
|
||||||
borderRadius={4}
|
className="h-[200px]"
|
||||||
enableLabel={true}
|
rotateLabelX={{ angle: -25, xAxisHeight: 40 }}
|
||||||
labelSkipHeight={12}
|
/>
|
||||||
labelTextColor="#ffffff"
|
|
||||||
margin={sharedMargin}
|
|
||||||
padding={0.3}
|
|
||||||
axisBottom={{
|
|
||||||
tickRotation: -25,
|
|
||||||
}}
|
|
||||||
animate={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -116,30 +96,18 @@ export function CrossStageComparisonChart({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div style={{ height: '200px' }}>
|
<BarChart
|
||||||
<ResponsiveBar
|
data={baseData}
|
||||||
data={baseData}
|
index="name"
|
||||||
keys={['completionRate']}
|
categories={['Completion Rate']}
|
||||||
indexBy="name"
|
colors={[BRAND_COLORS[1]] as string[]}
|
||||||
theme={nivoTheme}
|
showLegend={false}
|
||||||
colors={[BRAND_COLORS[1]]}
|
maxValue={100}
|
||||||
valueScale={{ type: 'linear', max: 100 }}
|
yAxisWidth={40}
|
||||||
borderRadius={4}
|
valueFormatter={(v) => `${v}%`}
|
||||||
enableLabel={true}
|
className="h-[200px]"
|
||||||
labelSkipHeight={12}
|
rotateLabelX={{ angle: -25, xAxisHeight: 40 }}
|
||||||
labelTextColor="#ffffff"
|
/>
|
||||||
valueFormat={(v) => `${v}%`}
|
|
||||||
margin={sharedMargin}
|
|
||||||
padding={0.3}
|
|
||||||
axisBottom={{
|
|
||||||
tickRotation: -25,
|
|
||||||
}}
|
|
||||||
axisLeft={{
|
|
||||||
format: (v) => `${v}%`,
|
|
||||||
}}
|
|
||||||
animate={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -150,26 +118,17 @@ export function CrossStageComparisonChart({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div style={{ height: '200px' }}>
|
<BarChart
|
||||||
<ResponsiveBar
|
data={baseData}
|
||||||
data={baseData}
|
index="name"
|
||||||
keys={['avgScore']}
|
categories={['Avg Score']}
|
||||||
indexBy="name"
|
colors={[BRAND_COLORS[0]] as string[]}
|
||||||
theme={nivoTheme}
|
showLegend={false}
|
||||||
colors={[BRAND_COLORS[0]]}
|
maxValue={10}
|
||||||
valueScale={{ type: 'linear', max: 10 }}
|
yAxisWidth={40}
|
||||||
borderRadius={4}
|
className="h-[200px]"
|
||||||
enableLabel={true}
|
rotateLabelX={{ angle: -25, xAxisHeight: 40 }}
|
||||||
labelSkipHeight={12}
|
/>
|
||||||
labelTextColor="#ffffff"
|
|
||||||
margin={sharedMargin}
|
|
||||||
padding={0.3}
|
|
||||||
axisBottom={{
|
|
||||||
tickRotation: -25,
|
|
||||||
}}
|
|
||||||
animate={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ResponsivePie } from '@nivo/pie'
|
import { DonutChart, BarChart } from '@tremor/react'
|
||||||
import { ResponsiveBar } from '@nivo/bar'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { nivoTheme, BRAND_COLORS } from './chart-theme'
|
import { BRAND_COLORS } from './chart-theme'
|
||||||
|
|
||||||
interface DiversityData {
|
interface DiversityData {
|
||||||
total: number
|
total: number
|
||||||
@@ -49,10 +48,10 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top countries for pie chart (max 10, others grouped)
|
// Top countries for donut chart (max 10, others grouped)
|
||||||
const topCountries = (data.byCountry || []).slice(0, 10)
|
const topCountries = (data.byCountry || []).slice(0, 10)
|
||||||
const otherCountries = (data.byCountry || []).slice(10)
|
const otherCountries = (data.byCountry || []).slice(10)
|
||||||
const countryPieData = otherCountries.length > 0
|
const countryData = otherCountries.length > 0
|
||||||
? [...topCountries, {
|
? [...topCountries, {
|
||||||
country: 'Others',
|
country: 'Others',
|
||||||
count: otherCountries.reduce((sum, c) => sum + c.count, 0),
|
count: otherCountries.reduce((sum, c) => sum + c.count, 0),
|
||||||
@@ -60,21 +59,19 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
|||||||
}]
|
}]
|
||||||
: topCountries
|
: topCountries
|
||||||
|
|
||||||
const nivoPieData = countryPieData.map((c) => ({
|
const donutData = countryData.map((c) => ({
|
||||||
id: c.country === 'Others' ? 'Others' : c.country.toUpperCase(),
|
name: getCountryName(c.country),
|
||||||
label: getCountryName(c.country),
|
|
||||||
value: c.count,
|
value: c.count,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Pre-format category and ocean issue data for display
|
const categoryData = (data.byCategory || []).slice(0, 10).map((c) => ({
|
||||||
const formattedCategories = (data.byCategory || []).slice(0, 10).map((c) => ({
|
|
||||||
category: formatLabel(c.category),
|
category: formatLabel(c.category),
|
||||||
count: c.count,
|
Count: c.count,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const formattedOceanIssues = (data.byOceanIssue || []).slice(0, 15).map((o) => ({
|
const oceanIssueData = (data.byOceanIssue || []).slice(0, 15).map((o) => ({
|
||||||
issue: formatLabel(o.issue),
|
issue: formatLabel(o.issue),
|
||||||
count: o.count,
|
Count: o.count,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -114,45 +111,17 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
|||||||
<CardTitle>Geographic Distribution</CardTitle>
|
<CardTitle>Geographic Distribution</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div style={{ height: '400px' }}>
|
{donutData.length > 0 ? (
|
||||||
{nivoPieData.length > 0 ? <ResponsivePie
|
<DonutChart
|
||||||
data={nivoPieData}
|
data={donutData}
|
||||||
theme={nivoTheme}
|
category="value"
|
||||||
colors={[...BRAND_COLORS]}
|
index="name"
|
||||||
innerRadius={0.4}
|
colors={[...BRAND_COLORS] as string[]}
|
||||||
padAngle={0.5}
|
className="h-[400px]"
|
||||||
cornerRadius={3}
|
/>
|
||||||
activeOuterRadiusOffset={8}
|
) : (
|
||||||
margin={{ top: 40, right: 80, bottom: 80, left: 80 }}
|
<p className="text-muted-foreground text-center py-8">No geographic data</p>
|
||||||
enableArcLinkLabels={true}
|
)}
|
||||||
arcLinkLabelsSkipAngle={10}
|
|
||||||
arcLinkLabelsTextColor="#374151"
|
|
||||||
arcLinkLabelsThickness={2}
|
|
||||||
arcLinkLabelsColor={{ from: 'color' }}
|
|
||||||
enableArcLabels={true}
|
|
||||||
arcLabelsSkipAngle={10}
|
|
||||||
arcLabelsTextColor={{ from: 'color', modifiers: [['darker', 2]] }}
|
|
||||||
legends={[
|
|
||||||
{
|
|
||||||
anchor: 'bottom',
|
|
||||||
direction: 'row',
|
|
||||||
justify: false,
|
|
||||||
translateX: 0,
|
|
||||||
translateY: 56,
|
|
||||||
itemsSpacing: 0,
|
|
||||||
itemWidth: 100,
|
|
||||||
itemHeight: 18,
|
|
||||||
itemTextColor: '#374151',
|
|
||||||
itemDirection: 'left-to-right',
|
|
||||||
itemOpacity: 1,
|
|
||||||
symbolSize: 12,
|
|
||||||
symbolShape: 'circle',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/> : (
|
|
||||||
<p className="text-muted-foreground text-center py-8">No geographic data</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -162,29 +131,17 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
|||||||
<CardTitle>Competition Categories</CardTitle>
|
<CardTitle>Competition Categories</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{formattedCategories.length > 0 ? (
|
{categoryData.length > 0 ? (
|
||||||
<div style={{ height: '400px' }}>
|
<BarChart
|
||||||
<ResponsiveBar
|
data={categoryData}
|
||||||
data={formattedCategories}
|
index="category"
|
||||||
theme={nivoTheme}
|
categories={['Count']}
|
||||||
keys={['count']}
|
colors={[BRAND_COLORS[0]] as string[]}
|
||||||
indexBy="category"
|
layout="horizontal"
|
||||||
layout="horizontal"
|
yAxisWidth={120}
|
||||||
colors={[BRAND_COLORS[0]]}
|
showLegend={false}
|
||||||
borderRadius={4}
|
className="h-[400px]"
|
||||||
margin={{ top: 10, right: 30, bottom: 10, left: 120 }}
|
/>
|
||||||
padding={0.3}
|
|
||||||
enableLabel={true}
|
|
||||||
labelTextColor="#ffffff"
|
|
||||||
enableGridX={true}
|
|
||||||
enableGridY={false}
|
|
||||||
axisBottom={null}
|
|
||||||
axisLeft={{
|
|
||||||
tickSize: 0,
|
|
||||||
tickPadding: 8,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-center py-8">No category data</p>
|
<p className="text-muted-foreground text-center py-8">No category data</p>
|
||||||
)}
|
)}
|
||||||
@@ -193,38 +150,22 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ocean Issues */}
|
{/* Ocean Issues */}
|
||||||
{formattedOceanIssues.length > 0 && (
|
{oceanIssueData.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Ocean Issues Addressed</CardTitle>
|
<CardTitle>Ocean Issues Addressed</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div style={{ height: '400px' }}>
|
<BarChart
|
||||||
<ResponsiveBar
|
data={oceanIssueData}
|
||||||
data={formattedOceanIssues}
|
index="issue"
|
||||||
theme={nivoTheme}
|
categories={['Count']}
|
||||||
keys={['count']}
|
colors={[BRAND_COLORS[2]] as string[]}
|
||||||
indexBy="issue"
|
showLegend={false}
|
||||||
layout="vertical"
|
yAxisWidth={40}
|
||||||
colors={[BRAND_COLORS[2]]}
|
className="h-[400px]"
|
||||||
borderRadius={4}
|
rotateLabelX={{ angle: -35, xAxisHeight: 80 }}
|
||||||
margin={{ top: 20, right: 30, bottom: 80, left: 40 }}
|
/>
|
||||||
padding={0.3}
|
|
||||||
enableLabel={true}
|
|
||||||
labelTextColor="#ffffff"
|
|
||||||
enableGridX={false}
|
|
||||||
enableGridY={true}
|
|
||||||
axisBottom={{
|
|
||||||
tickSize: 0,
|
|
||||||
tickPadding: 8,
|
|
||||||
tickRotation: -35,
|
|
||||||
}}
|
|
||||||
axisLeft={{
|
|
||||||
tickSize: 0,
|
|
||||||
tickPadding: 8,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ResponsiveLine } from '@nivo/line'
|
import { AreaChart } from '@tremor/react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { nivoTheme, BRAND_DARK_BLUE } from './chart-theme'
|
import { BRAND_DARK_BLUE, BRAND_TEAL } from './chart-theme'
|
||||||
|
|
||||||
interface TimelineDataPoint {
|
interface TimelineDataPoint {
|
||||||
date: string
|
date: string
|
||||||
@@ -17,26 +17,17 @@ interface EvaluationTimelineProps {
|
|||||||
export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
||||||
if (!data?.length) return null
|
if (!data?.length) return null
|
||||||
|
|
||||||
const formattedData = data.map((d) => ({
|
|
||||||
...d,
|
|
||||||
dateFormatted: new Date(d.date).toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const totalEvaluations =
|
const totalEvaluations =
|
||||||
data.length > 0 ? data[data.length - 1].cumulative : 0
|
data.length > 0 ? data[data.length - 1].cumulative : 0
|
||||||
|
|
||||||
const lineData = [
|
const chartData = data.map((d) => ({
|
||||||
{
|
date: new Date(d.date).toLocaleDateString('en-US', {
|
||||||
id: 'Cumulative Evaluations',
|
month: 'short',
|
||||||
data: formattedData.map((d) => ({
|
day: 'numeric',
|
||||||
x: d.dateFormatted,
|
}),
|
||||||
y: d.cumulative,
|
Cumulative: d.cumulative,
|
||||||
})),
|
Daily: d.daily,
|
||||||
},
|
}))
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -49,57 +40,16 @@ export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div style={{ height: '300px' }}>
|
<AreaChart
|
||||||
<ResponsiveLine
|
data={chartData}
|
||||||
data={lineData}
|
index="date"
|
||||||
theme={nivoTheme}
|
categories={['Cumulative', 'Daily']}
|
||||||
colors={[BRAND_DARK_BLUE]}
|
colors={[BRAND_DARK_BLUE, BRAND_TEAL] as string[]}
|
||||||
enableArea={true}
|
curveType="monotone"
|
||||||
areaOpacity={0.1}
|
showGradient={true}
|
||||||
areaBaselineValue={0}
|
yAxisWidth={50}
|
||||||
curve="monotoneX"
|
className="h-[300px]"
|
||||||
pointSize={6}
|
/>
|
||||||
pointColor={BRAND_DARK_BLUE}
|
|
||||||
pointBorderWidth={2}
|
|
||||||
pointBorderColor="#ffffff"
|
|
||||||
useMesh={true}
|
|
||||||
enableSlices={formattedData.length >= 2 ? 'x' : false}
|
|
||||||
sliceTooltip={({ slice }) => {
|
|
||||||
const point = slice.points[0]
|
|
||||||
if (!point) return null
|
|
||||||
const dataItem = formattedData.find(
|
|
||||||
(d) => d.dateFormatted === point.data.xFormatted
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#fff',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>{point.data.xFormatted}</strong>
|
|
||||||
<div>Cumulative: {point.data.yFormatted}</div>
|
|
||||||
{dataItem && <div>Daily: {dataItem.daily}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
margin={{ top: 20, right: 20, bottom: 50, left: 60 }}
|
|
||||||
axisBottom={{
|
|
||||||
tickRotation: -45,
|
|
||||||
legend: '',
|
|
||||||
legendOffset: 36,
|
|
||||||
}}
|
|
||||||
axisLeft={{
|
|
||||||
legend: 'Evaluations',
|
|
||||||
legendOffset: -50,
|
|
||||||
legendPosition: 'middle',
|
|
||||||
}}
|
|
||||||
yScale={{ type: 'linear', min: 0, max: 'auto' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ResponsiveScatterPlot } from '@nivo/scatterplot'
|
import { ScatterChart } from '@tremor/react'
|
||||||
import type {
|
|
||||||
ScatterPlotDatum,
|
|
||||||
ScatterPlotNodeProps,
|
|
||||||
} from '@nivo/scatterplot'
|
|
||||||
import { animated } from '@react-spring/web'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +12,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { AlertTriangle } from 'lucide-react'
|
import { AlertTriangle } from 'lucide-react'
|
||||||
import { nivoTheme, BRAND_DARK_BLUE, BRAND_RED } from './chart-theme'
|
import { BRAND_DARK_BLUE, BRAND_RED } from './chart-theme'
|
||||||
|
|
||||||
interface JurorMetric {
|
interface JurorMetric {
|
||||||
userId: string
|
userId: string
|
||||||
@@ -36,60 +31,6 @@ interface JurorConsistencyProps {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JurorDatum extends ScatterPlotDatum {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
name: string
|
|
||||||
evaluations: number
|
|
||||||
isOutlier: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomNode({
|
|
||||||
node,
|
|
||||||
style,
|
|
||||||
blendMode,
|
|
||||||
isInteractive,
|
|
||||||
onMouseEnter,
|
|
||||||
onMouseMove,
|
|
||||||
onMouseLeave,
|
|
||||||
onClick,
|
|
||||||
}: ScatterPlotNodeProps<JurorDatum>) {
|
|
||||||
const fillColor = node.data.isOutlier ? BRAND_RED : BRAND_DARK_BLUE
|
|
||||||
|
|
||||||
return (
|
|
||||||
<animated.circle
|
|
||||||
cx={style.x}
|
|
||||||
cy={style.y}
|
|
||||||
r={style.size.to((s: number) => s / 2)}
|
|
||||||
fill={fillColor}
|
|
||||||
fillOpacity={0.7}
|
|
||||||
stroke={fillColor}
|
|
||||||
strokeWidth={1}
|
|
||||||
style={{ mixBlendMode: blendMode }}
|
|
||||||
onMouseEnter={
|
|
||||||
isInteractive && onMouseEnter
|
|
||||||
? (event) => onMouseEnter(node, event)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onMouseMove={
|
|
||||||
isInteractive && onMouseMove
|
|
||||||
? (event) => onMouseMove(node, event)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onMouseLeave={
|
|
||||||
isInteractive && onMouseLeave
|
|
||||||
? (event) => onMouseLeave(node, event)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onClick={
|
|
||||||
isInteractive && onClick
|
|
||||||
? (event) => onClick(node, event)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
||||||
if (!data?.jurors?.length) {
|
if (!data?.jurors?.length) {
|
||||||
return (
|
return (
|
||||||
@@ -101,21 +42,17 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scatterData = [
|
|
||||||
{
|
|
||||||
id: 'Jurors',
|
|
||||||
data: data.jurors.map((j) => ({
|
|
||||||
x: parseFloat(j.averageScore.toFixed(2)),
|
|
||||||
y: parseFloat(j.stddev.toFixed(2)),
|
|
||||||
name: j.name,
|
|
||||||
evaluations: j.evaluationCount,
|
|
||||||
isOutlier: j.isOutlier,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const outlierCount = data.jurors.filter((j) => j.isOutlier).length
|
const outlierCount = data.jurors.filter((j) => j.isOutlier).length
|
||||||
|
|
||||||
|
const scatterData = data.jurors.map((j) => ({
|
||||||
|
'Average Score': parseFloat(j.averageScore.toFixed(2)),
|
||||||
|
'Std Deviation': parseFloat(j.stddev.toFixed(2)),
|
||||||
|
category: j.isOutlier ? 'Outlier' : 'Normal',
|
||||||
|
name: j.name,
|
||||||
|
evaluations: j.evaluationCount,
|
||||||
|
size: Math.max(8, Math.min(20, j.evaluationCount * 2)),
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Scatter: Average Score vs Standard Deviation */}
|
{/* Scatter: Average Score vs Standard Deviation */}
|
||||||
@@ -134,60 +71,15 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div style={{ height: '400px' }}>
|
<ScatterChart
|
||||||
<ResponsiveScatterPlot<JurorDatum>
|
data={scatterData}
|
||||||
data={scatterData}
|
x="Average Score"
|
||||||
theme={nivoTheme}
|
y="Std Deviation"
|
||||||
colors={[BRAND_DARK_BLUE]}
|
category="category"
|
||||||
xScale={{ type: 'linear', min: 0, max: 10 }}
|
size="size"
|
||||||
yScale={{ type: 'linear', min: 0, max: 'auto' }}
|
colors={[BRAND_DARK_BLUE, BRAND_RED] as string[]}
|
||||||
axisBottom={{
|
className="h-[400px]"
|
||||||
legend: 'Average Score',
|
/>
|
||||||
legendPosition: 'middle',
|
|
||||||
legendOffset: 40,
|
|
||||||
}}
|
|
||||||
axisLeft={{
|
|
||||||
legend: 'Std Deviation',
|
|
||||||
legendPosition: 'middle',
|
|
||||||
legendOffset: -50,
|
|
||||||
}}
|
|
||||||
useMesh={true}
|
|
||||||
nodeSize={(node) =>
|
|
||||||
Math.max(8, Math.min(20, node.data.evaluations * 2))
|
|
||||||
}
|
|
||||||
nodeComponent={CustomNode}
|
|
||||||
margin={{ top: 20, right: 20, bottom: 60, left: 60 }}
|
|
||||||
tooltip={({ node }) => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#fff',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>{node.data.name}</strong>
|
|
||||||
<div>Avg Score: {node.data.x}</div>
|
|
||||||
<div>Std Dev: {node.data.y}</div>
|
|
||||||
<div>Evaluations: {node.data.evaluations}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
markers={[
|
|
||||||
{
|
|
||||||
axis: 'x',
|
|
||||||
value: data.overallAverage,
|
|
||||||
lineStyle: {
|
|
||||||
stroke: BRAND_RED,
|
|
||||||
strokeWidth: 2,
|
|
||||||
strokeDasharray: '6 4',
|
|
||||||
},
|
|
||||||
legend: `Avg: ${data.overallAverage.toFixed(1)}`,
|
|
||||||
legendPosition: 'top',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||||
Dot size represents number of evaluations. Red dots indicate outlier
|
Dot size represents number of evaluations. Red dots indicate outlier
|
||||||
jurors (2+ points from mean).
|
jurors (2+ points from mean).
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ResponsiveBar, type ComputedDatum } from '@nivo/bar'
|
import { BarChart } from '@tremor/react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { nivoTheme } from './chart-theme'
|
import { BRAND_DARK_BLUE } from './chart-theme'
|
||||||
|
|
||||||
interface JurorWorkloadData {
|
interface JurorWorkloadData {
|
||||||
id: string
|
id: string
|
||||||
@@ -16,14 +16,6 @@ interface JurorWorkloadProps {
|
|||||||
data: JurorWorkloadData[]
|
data: JurorWorkloadData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkloadBarDatum = {
|
|
||||||
juror: string
|
|
||||||
completed: number
|
|
||||||
remaining: number
|
|
||||||
completionRate: number
|
|
||||||
fullName: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
||||||
if (!data?.length) return null
|
if (!data?.length) return null
|
||||||
|
|
||||||
@@ -36,12 +28,10 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
|||||||
(a, b) => b.completionRate - a.completionRate,
|
(a, b) => b.completionRate - a.completionRate,
|
||||||
)
|
)
|
||||||
|
|
||||||
const chartData: WorkloadBarDatum[] = sortedData.map((d) => ({
|
const chartData = sortedData.map((d) => ({
|
||||||
juror: d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name,
|
juror: d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name,
|
||||||
completed: d.completed,
|
Completed: d.completed,
|
||||||
remaining: d.assigned - d.completed,
|
Remaining: d.assigned - d.completed,
|
||||||
completionRate: d.completionRate,
|
|
||||||
fullName: d.name,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -55,66 +45,17 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div
|
<BarChart
|
||||||
|
data={chartData}
|
||||||
|
index="juror"
|
||||||
|
categories={['Completed', 'Remaining']}
|
||||||
|
colors={[BRAND_DARK_BLUE, '#e5e7eb'] as string[]}
|
||||||
|
layout="horizontal"
|
||||||
|
stack={true}
|
||||||
|
yAxisWidth={160}
|
||||||
|
className={`h-[${Math.max(300, data.length * 35)}px]`}
|
||||||
style={{ height: `${Math.max(300, data.length * 35)}px` }}
|
style={{ height: `${Math.max(300, data.length * 35)}px` }}
|
||||||
>
|
/>
|
||||||
<ResponsiveBar
|
|
||||||
data={chartData}
|
|
||||||
keys={['completed', 'remaining']}
|
|
||||||
indexBy="juror"
|
|
||||||
layout="horizontal"
|
|
||||||
theme={nivoTheme}
|
|
||||||
colors={['#053d57', '#e5e7eb']}
|
|
||||||
borderRadius={2}
|
|
||||||
enableLabel={true}
|
|
||||||
label={(d: ComputedDatum<WorkloadBarDatum>) => {
|
|
||||||
if (d.id === 'completed') {
|
|
||||||
return `${d.data.completionRate}%`
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}}
|
|
||||||
labelSkipWidth={40}
|
|
||||||
labelTextColor={(d) => {
|
|
||||||
const datum = d as unknown as { data: ComputedDatum<WorkloadBarDatum> }
|
|
||||||
return datum.data.id === 'completed' ? '#ffffff' : '#374151'
|
|
||||||
}}
|
|
||||||
margin={{ top: 10, right: 30, bottom: 30, left: 160 }}
|
|
||||||
padding={0.25}
|
|
||||||
groupMode="stacked"
|
|
||||||
tooltip={({ id, value, data: rowData }) => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>{rowData.fullName}</strong>
|
|
||||||
<br />
|
|
||||||
{id === 'completed' ? 'Completed' : 'Remaining'}: {value}
|
|
||||||
<br />
|
|
||||||
Completion: {rowData.completionRate}%
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
legends={[
|
|
||||||
{
|
|
||||||
dataFrom: 'keys',
|
|
||||||
anchor: 'bottom',
|
|
||||||
direction: 'row',
|
|
||||||
translateY: 30,
|
|
||||||
itemsSpacing: 20,
|
|
||||||
itemWidth: 100,
|
|
||||||
itemHeight: 18,
|
|
||||||
symbolSize: 12,
|
|
||||||
symbolShape: 'square',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
animate={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ResponsiveBar } from '@nivo/bar'
|
import { BarChart } from '@tremor/react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { nivoTheme, scoreGradient } from './chart-theme'
|
import { BRAND_TEAL } from './chart-theme'
|
||||||
|
|
||||||
interface ProjectRankingData {
|
interface ProjectRankingData {
|
||||||
id: string
|
id: string
|
||||||
@@ -18,14 +18,6 @@ interface ProjectRankingsProps {
|
|||||||
limit?: number
|
limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type RankingBarDatum = {
|
|
||||||
project: string
|
|
||||||
score: number
|
|
||||||
fullTitle: string
|
|
||||||
teamName: string
|
|
||||||
evaluationCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProjectRankingsChart({
|
export function ProjectRankingsChart({
|
||||||
data,
|
data,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
@@ -37,21 +29,12 @@ export function ProjectRankingsChart({
|
|||||||
|
|
||||||
if (!scoredData.length) return null
|
if (!scoredData.length) return null
|
||||||
|
|
||||||
const averageScore =
|
|
||||||
scoredData.length > 0
|
|
||||||
? scoredData.reduce((sum, d) => sum + d.averageScore, 0) /
|
|
||||||
scoredData.length
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const displayData = scoredData.slice(0, limit)
|
const displayData = scoredData.slice(0, limit)
|
||||||
|
|
||||||
const chartData: RankingBarDatum[] = displayData.map((d) => ({
|
const chartData = displayData.map((d) => ({
|
||||||
project:
|
project:
|
||||||
d.title.length > 30 ? d.title.substring(0, 30) + '...' : d.title,
|
d.title.length > 30 ? d.title.substring(0, 30) + '...' : d.title,
|
||||||
score: d.averageScore,
|
Score: parseFloat(d.averageScore.toFixed(2)),
|
||||||
fullTitle: d.title,
|
|
||||||
teamName: d.teamName ?? '',
|
|
||||||
evaluationCount: d.evaluationCount,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -65,75 +48,18 @@ export function ProjectRankingsChart({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div
|
<BarChart
|
||||||
style={{
|
data={chartData}
|
||||||
height: `${Math.max(400, displayData.length * 30)}px`,
|
index="project"
|
||||||
}}
|
categories={['Score']}
|
||||||
>
|
colors={[BRAND_TEAL] as string[]}
|
||||||
<ResponsiveBar
|
layout="horizontal"
|
||||||
data={chartData}
|
yAxisWidth={200}
|
||||||
keys={['score']}
|
maxValue={10}
|
||||||
indexBy="project"
|
showLegend={false}
|
||||||
layout="horizontal"
|
className={`h-[${Math.max(400, displayData.length * 30)}px]`}
|
||||||
theme={nivoTheme}
|
style={{ height: `${Math.max(400, displayData.length * 30)}px` }}
|
||||||
colors={(bar) => scoreGradient(bar.data.score as number)}
|
/>
|
||||||
valueScale={{ type: 'linear', max: 10 }}
|
|
||||||
borderRadius={4}
|
|
||||||
enableLabel={true}
|
|
||||||
label={(d) => {
|
|
||||||
const v = d.value
|
|
||||||
return v != null ? Number(v).toFixed(1) : ''
|
|
||||||
}}
|
|
||||||
labelSkipWidth={30}
|
|
||||||
labelTextColor="#ffffff"
|
|
||||||
margin={{ top: 10, right: 30, bottom: 30, left: 200 }}
|
|
||||||
padding={0.2}
|
|
||||||
markers={[
|
|
||||||
{
|
|
||||||
axis: 'x',
|
|
||||||
value: averageScore,
|
|
||||||
lineStyle: {
|
|
||||||
stroke: '#6b7280',
|
|
||||||
strokeWidth: 2,
|
|
||||||
strokeDasharray: '6 4',
|
|
||||||
},
|
|
||||||
legend: `Avg: ${averageScore.toFixed(1)}`,
|
|
||||||
legendPosition: 'top',
|
|
||||||
textStyle: {
|
|
||||||
fill: '#6b7280',
|
|
||||||
fontSize: 11,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
tooltip={({ data: rowData }) => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>{rowData.fullTitle}</strong>
|
|
||||||
{rowData.teamName && (
|
|
||||||
<>
|
|
||||||
<br />
|
|
||||||
<span style={{ color: '#6b7280' }}>
|
|
||||||
{rowData.teamName}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<br />
|
|
||||||
Score: {Number(rowData.score).toFixed(2)}
|
|
||||||
<br />
|
|
||||||
Evaluations: {rowData.evaluationCount}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
animate={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ResponsiveBar } from '@nivo/bar'
|
import { BarChart } from '@tremor/react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { nivoTheme, scoreGradient } from './chart-theme'
|
import { BRAND_TEAL } from './chart-theme'
|
||||||
|
|
||||||
interface ScoreDistributionProps {
|
interface ScoreDistributionProps {
|
||||||
data: { score: number; count: number }[]
|
data: { score: number; count: number }[]
|
||||||
@@ -19,7 +19,7 @@ export function ScoreDistributionChart({
|
|||||||
|
|
||||||
const chartData = data.map((d) => ({
|
const chartData = data.map((d) => ({
|
||||||
score: String(d.score),
|
score: String(d.score),
|
||||||
count: d.count,
|
Count: d.count,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,32 +33,15 @@ export function ScoreDistributionChart({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div style={{ height: '300px' }}>
|
<BarChart
|
||||||
<ResponsiveBar
|
data={chartData}
|
||||||
data={chartData}
|
index="score"
|
||||||
keys={['count']}
|
categories={['Count']}
|
||||||
indexBy="score"
|
colors={[BRAND_TEAL] as (string)[]}
|
||||||
theme={nivoTheme}
|
yAxisWidth={40}
|
||||||
colors={(bar) => scoreGradient(Number(bar.indexValue))}
|
showLegend={false}
|
||||||
borderRadius={4}
|
className="h-[300px]"
|
||||||
enableLabel={true}
|
/>
|
||||||
labelSkipHeight={12}
|
|
||||||
labelTextColor="#ffffff"
|
|
||||||
axisBottom={{
|
|
||||||
legend: 'Score',
|
|
||||||
legendPosition: 'middle',
|
|
||||||
legendOffset: 36,
|
|
||||||
}}
|
|
||||||
axisLeft={{
|
|
||||||
legend: 'Count',
|
|
||||||
legendPosition: 'middle',
|
|
||||||
legendOffset: -40,
|
|
||||||
}}
|
|
||||||
margin={{ top: 20, right: 20, bottom: 50, left: 50 }}
|
|
||||||
padding={0.2}
|
|
||||||
animate={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ResponsivePie } from '@nivo/pie'
|
import { DonutChart } from '@tremor/react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { nivoTheme, getStatusColor, formatStatus } from './chart-theme'
|
import { getStatusColor, formatStatus } from './chart-theme'
|
||||||
|
|
||||||
interface StatusDataPoint {
|
interface StatusDataPoint {
|
||||||
status: string
|
status: string
|
||||||
@@ -18,13 +18,13 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
|
|||||||
|
|
||||||
const total = data.reduce((sum, item) => sum + item.count, 0)
|
const total = data.reduce((sum, item) => sum + item.count, 0)
|
||||||
|
|
||||||
const pieData = data.map((d) => ({
|
const chartData = data.map((d) => ({
|
||||||
id: d.status,
|
name: formatStatus(d.status),
|
||||||
label: formatStatus(d.status),
|
|
||||||
value: d.count,
|
value: d.count,
|
||||||
color: getStatusColor(d.status),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const colors = data.map((d) => getStatusColor(d.status))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -36,43 +36,14 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div style={{ height: '300px' }}>
|
<DonutChart
|
||||||
<ResponsivePie
|
data={chartData}
|
||||||
data={pieData}
|
category="value"
|
||||||
theme={nivoTheme}
|
index="name"
|
||||||
colors={{ datum: 'data.color' }}
|
colors={colors as string[]}
|
||||||
innerRadius={0.5}
|
showLabel={true}
|
||||||
padAngle={0.7}
|
className="h-[300px]"
|
||||||
cornerRadius={3}
|
/>
|
||||||
activeOuterRadiusOffset={8}
|
|
||||||
margin={{ top: 40, right: 80, bottom: 80, left: 80 }}
|
|
||||||
enableArcLinkLabels={true}
|
|
||||||
arcLinkLabelsSkipAngle={10}
|
|
||||||
arcLinkLabelsTextColor="#374151"
|
|
||||||
arcLinkLabelsThickness={2}
|
|
||||||
arcLinkLabelsColor={{ from: 'color' }}
|
|
||||||
enableArcLabels={true}
|
|
||||||
arcLabelsSkipAngle={10}
|
|
||||||
arcLabelsTextColor={{ from: 'color', modifiers: [['darker', 2]] }}
|
|
||||||
legends={[
|
|
||||||
{
|
|
||||||
anchor: 'bottom',
|
|
||||||
direction: 'row',
|
|
||||||
justify: false,
|
|
||||||
translateX: 0,
|
|
||||||
translateY: 56,
|
|
||||||
itemsSpacing: 0,
|
|
||||||
itemWidth: 100,
|
|
||||||
itemHeight: 18,
|
|
||||||
itemTextColor: '#374151',
|
|
||||||
itemDirection: 'left-to-right',
|
|
||||||
itemOpacity: 1,
|
|
||||||
symbolSize: 12,
|
|
||||||
symbolShape: 'circle',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { BarChart3, Home } from 'lucide-react'
|
import { BarChart3, Home, FolderKanban } from 'lucide-react'
|
||||||
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||||
|
|
||||||
interface ObserverNavProps {
|
interface ObserverNavProps {
|
||||||
@@ -14,6 +14,11 @@ export function ObserverNav({ user }: ObserverNavProps) {
|
|||||||
href: '/observer',
|
href: '/observer',
|
||||||
icon: Home,
|
icon: Home,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Projects',
|
||||||
|
href: '/observer/projects',
|
||||||
|
icon: FolderKanban,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Reports',
|
name: 'Reports',
|
||||||
href: '/observer/reports',
|
href: '/observer/reports',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
487
src/components/observer/observer-projects-content.tsx
Normal file
487
src/components/observer/observer-projects-content.tsx
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { StatusBadge } from '@/components/shared/status-badge'
|
||||||
|
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||||
|
import { scoreGradient } from '@/components/charts/chart-theme'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
ClipboardList,
|
||||||
|
Download,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useDebouncedCallback } from 'use-debounce'
|
||||||
|
|
||||||
|
|
||||||
|
export function ObserverProjectsContent() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||||
|
const [roundFilter, setRoundFilter] = useState('all')
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all')
|
||||||
|
const [sortBy, setSortBy] = useState<'title' | 'score' | 'evaluations'>('title')
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [perPage] = useState(20)
|
||||||
|
const [csvOpen, setCsvOpen] = useState(false)
|
||||||
|
const [csvExportData, setCsvExportData] = useState<
|
||||||
|
{ data: Record<string, unknown>[]; columns: string[] } | undefined
|
||||||
|
>(undefined)
|
||||||
|
const [csvLoading, setCsvLoading] = useState(false)
|
||||||
|
|
||||||
|
const debouncedSetSearch = useDebouncedCallback((value: string) => {
|
||||||
|
setDebouncedSearch(value)
|
||||||
|
setPage(1)
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
|
setSearch(value)
|
||||||
|
debouncedSetSearch(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRoundChange = (value: string) => {
|
||||||
|
setRoundFilter(value)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStatusChange = (value: string) => {
|
||||||
|
setStatusFilter(value)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSort = (column: 'title' | 'score' | 'evaluations') => {
|
||||||
|
if (sortBy === column) {
|
||||||
|
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
setSortBy(column)
|
||||||
|
setSortDir(column === 'title' ? 'asc' : 'desc')
|
||||||
|
}
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearch('')
|
||||||
|
setDebouncedSearch('')
|
||||||
|
setRoundFilter('all')
|
||||||
|
setStatusFilter('all')
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeFilterCount =
|
||||||
|
(debouncedSearch ? 1 : 0) +
|
||||||
|
(roundFilter !== 'all' ? 1 : 0) +
|
||||||
|
(statusFilter !== 'all' ? 1 : 0)
|
||||||
|
|
||||||
|
const { data: programs } = trpc.program.list.useQuery(
|
||||||
|
{ includeStages: true },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const rounds =
|
||||||
|
programs?.flatMap((p) =>
|
||||||
|
(p.rounds ?? []).map((r: { id: string; name: string; status: string; roundType?: string }) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
programName: `${p.year} Edition`,
|
||||||
|
status: r.status,
|
||||||
|
roundType: r.roundType,
|
||||||
|
})),
|
||||||
|
) ?? []
|
||||||
|
|
||||||
|
const roundIdParam = roundFilter !== 'all' ? roundFilter : undefined
|
||||||
|
|
||||||
|
const { data: projectsData, isLoading: projectsLoading } =
|
||||||
|
trpc.analytics.getAllProjects.useQuery(
|
||||||
|
{
|
||||||
|
roundId: roundIdParam,
|
||||||
|
search: debouncedSearch || undefined,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
sortBy,
|
||||||
|
sortDir,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
},
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRequestCsvData = useCallback(async () => {
|
||||||
|
setCsvLoading(true)
|
||||||
|
try {
|
||||||
|
const allData = await new Promise<typeof projectsData>((resolve) => {
|
||||||
|
resolve(projectsData)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!allData?.projects) {
|
||||||
|
setCsvLoading(false)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = allData.projects.map((p) => ({
|
||||||
|
title: p.title,
|
||||||
|
teamName: p.teamName ?? '',
|
||||||
|
country: p.country ?? '',
|
||||||
|
roundName: p.roundName ?? '',
|
||||||
|
status: p.status,
|
||||||
|
averageScore: p.averageScore !== null ? p.averageScore.toFixed(2) : '',
|
||||||
|
evaluationCount: p.evaluationCount,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
data: rows,
|
||||||
|
columns: ['title', 'teamName', 'country', 'roundName', 'status', 'averageScore', 'evaluationCount'],
|
||||||
|
}
|
||||||
|
setCsvExportData(result)
|
||||||
|
setCsvLoading(false)
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
setCsvLoading(false)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}, [projectsData])
|
||||||
|
|
||||||
|
const SortIcon = ({ column }: { column: 'title' | 'score' | 'evaluations' }) => {
|
||||||
|
if (sortBy !== column)
|
||||||
|
return <ArrowUpDown className="ml-1 inline h-3 w-3 text-muted-foreground/50" />
|
||||||
|
return sortDir === 'asc' ? (
|
||||||
|
<ArrowUp className="ml-1 inline h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="ml-1 inline h-3 w-3" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">All Projects</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{projectsData
|
||||||
|
? `${projectsData.total} project${projectsData.total !== 1 ? 's' : ''} total`
|
||||||
|
: 'Loading projects...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setCsvOpen(true)}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Filters</CardTitle>
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<CardDescription className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary">{activeFilterCount} active</Badge>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by title or team..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={roundFilter} onValueChange={handleRoundChange}>
|
||||||
|
<SelectTrigger className="w-full sm:w-[220px]">
|
||||||
|
<SelectValue placeholder="All Rounds" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Rounds</SelectItem>
|
||||||
|
{rounds.map((round) => (
|
||||||
|
<SelectItem key={round.id} value={round.id}>
|
||||||
|
{round.name}
|
||||||
|
{round.roundType ? ` (${round.roundType.replace(/_/g, ' ')})` : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={statusFilter} onValueChange={handleStatusChange}>
|
||||||
|
<SelectTrigger className="w-full sm:w-[180px]">
|
||||||
|
<SelectValue placeholder="All Statuses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Statuses</SelectItem>
|
||||||
|
<SelectItem value="SUBMITTED">Submitted</SelectItem>
|
||||||
|
<SelectItem value="ELIGIBLE">Eligible</SelectItem>
|
||||||
|
<SelectItem value="ASSIGNED">Assigned</SelectItem>
|
||||||
|
<SelectItem value="SEMIFINALIST">Semi-finalist</SelectItem>
|
||||||
|
<SelectItem value="FINALIST">Finalist</SelectItem>
|
||||||
|
<SelectItem value="REJECTED">Rejected</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{projectsLoading ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 space-y-2">
|
||||||
|
{[...Array(8)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : projectsData && projectsData.projects.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="pl-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSort('title')}
|
||||||
|
className="inline-flex items-center hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Project
|
||||||
|
<SortIcon column="title" />
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Country</TableHead>
|
||||||
|
<TableHead>Round</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSort('score')}
|
||||||
|
className="inline-flex items-center hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Score
|
||||||
|
<SortIcon column="score" />
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSort('evaluations')}
|
||||||
|
className="inline-flex items-center hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Jurors
|
||||||
|
<SortIcon column="evaluations" />
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="pr-6 w-10" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{projectsData.projects.map((project) => (
|
||||||
|
<TableRow
|
||||||
|
key={project.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => router.push(`/observer/projects/${project.id}`)}
|
||||||
|
>
|
||||||
|
<TableCell className="pl-6 max-w-[260px]">
|
||||||
|
<Link
|
||||||
|
href={`/observer/projects/${project.id}` as Route}
|
||||||
|
className="font-medium hover:underline truncate block"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{project.title}
|
||||||
|
</Link>
|
||||||
|
{project.teamName && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{project.teamName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{project.country ?? '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-xs whitespace-nowrap">
|
||||||
|
{project.roundName}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={project.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{project.averageScore !== null ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="tabular-nums w-8 text-sm">
|
||||||
|
{project.averageScore.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<div className="h-2 w-16 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${(project.averageScore / 10) * 100}%`,
|
||||||
|
backgroundColor: scoreGradient(project.averageScore),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="tabular-nums text-sm">
|
||||||
|
{project.evaluationCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="pr-6">
|
||||||
|
<Link
|
||||||
|
href={`/observer/projects/${project.id}` as Route}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 md:hidden">
|
||||||
|
{projectsData.projects.map((project) => (
|
||||||
|
<Link
|
||||||
|
key={project.id}
|
||||||
|
href={`/observer/projects/${project.id}` as Route}
|
||||||
|
>
|
||||||
|
<Card className="transition-colors hover:bg-muted/50">
|
||||||
|
<CardContent className="pt-4 space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-sm leading-tight truncate">
|
||||||
|
{project.title}
|
||||||
|
</p>
|
||||||
|
{project.teamName && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{project.teamName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={project.status} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{project.roundName}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span>
|
||||||
|
Score:{' '}
|
||||||
|
{project.averageScore !== null
|
||||||
|
? project.averageScore.toFixed(1)
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{project.evaluationCount} eval
|
||||||
|
{project.evaluationCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Page {projectsData.page} of {projectsData.totalPages} ·{' '}
|
||||||
|
{projectsData.total} result{projectsData.total !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setPage((p) => Math.min(projectsData.totalPages, p + 1))
|
||||||
|
}
|
||||||
|
disabled={page >= projectsData.totalPages}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center rounded-lg border border-dashed py-16 text-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-3 font-medium">
|
||||||
|
{activeFilterCount > 0 ? 'No projects match your filters' : 'No projects found'}
|
||||||
|
</p>
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<Button variant="ghost" size="sm" className="mt-2" onClick={clearFilters}>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CsvExportDialog
|
||||||
|
open={csvOpen}
|
||||||
|
onOpenChange={setCsvOpen}
|
||||||
|
exportData={csvExportData}
|
||||||
|
isLoading={csvLoading}
|
||||||
|
filename="observer-projects"
|
||||||
|
onRequestData={handleRequestCsvData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -627,92 +627,6 @@ export const analyticsRouter = router({
|
|||||||
return { total, byCountry, byCategory, byOceanIssue, byTag }
|
return { total, byCountry, byCategory, byOceanIssue, byTag }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
|
||||||
* Get year-over-year stats across all rounds in a program
|
|
||||||
*/
|
|
||||||
getYearOverYear: observerProcedure
|
|
||||||
.input(z.object({ programId: z.string() }))
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const competitions = await ctx.prisma.competition.findMany({
|
|
||||||
where: { programId: input.programId },
|
|
||||||
include: {
|
|
||||||
rounds: {
|
|
||||||
select: { id: true, name: true, createdAt: true },
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const allRounds = competitions.flatMap((c) => c.rounds)
|
|
||||||
const roundIds = allRounds.map((r) => r.id)
|
|
||||||
|
|
||||||
if (roundIds.length === 0) return []
|
|
||||||
|
|
||||||
// Batch: fetch assignments, evaluations, and distinct projects in 3 queries
|
|
||||||
const [assignmentCounts, evaluations, projectAssignments] = await Promise.all([
|
|
||||||
ctx.prisma.assignment.groupBy({
|
|
||||||
by: ['roundId'],
|
|
||||||
where: { roundId: { in: roundIds } },
|
|
||||||
_count: true,
|
|
||||||
}),
|
|
||||||
ctx.prisma.evaluation.findMany({
|
|
||||||
where: {
|
|
||||||
assignment: { roundId: { in: roundIds } },
|
|
||||||
status: 'SUBMITTED',
|
|
||||||
},
|
|
||||||
select: { globalScore: true, assignment: { select: { roundId: true } } },
|
|
||||||
}),
|
|
||||||
ctx.prisma.assignment.findMany({
|
|
||||||
where: { roundId: { in: roundIds } },
|
|
||||||
select: { roundId: true, projectId: true },
|
|
||||||
distinct: ['roundId', 'projectId'],
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
const assignmentCountMap = new Map(assignmentCounts.map((a) => [a.roundId, a._count]))
|
|
||||||
|
|
||||||
// Group evaluation scores by round
|
|
||||||
const scoresByRound = new Map<string, number[]>()
|
|
||||||
const evalCountByRound = new Map<string, number>()
|
|
||||||
for (const e of evaluations) {
|
|
||||||
const rid = e.assignment.roundId
|
|
||||||
evalCountByRound.set(rid, (evalCountByRound.get(rid) ?? 0) + 1)
|
|
||||||
if (e.globalScore !== null) {
|
|
||||||
if (!scoresByRound.has(rid)) scoresByRound.set(rid, [])
|
|
||||||
scoresByRound.get(rid)!.push(e.globalScore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count distinct projects per round
|
|
||||||
const projectsByRound = new Map<string, number>()
|
|
||||||
for (const pa of projectAssignments) {
|
|
||||||
projectsByRound.set(pa.roundId, (projectsByRound.get(pa.roundId) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return allRounds.map((round) => {
|
|
||||||
const scores = scoresByRound.get(round.id) ?? []
|
|
||||||
const assignmentCount = assignmentCountMap.get(round.id) ?? 0
|
|
||||||
const evaluationCount = evalCountByRound.get(round.id) ?? 0
|
|
||||||
const completionRate = assignmentCount > 0
|
|
||||||
? Math.round((evaluationCount / assignmentCount) * 100)
|
|
||||||
: 0
|
|
||||||
const averageScore = scores.length > 0
|
|
||||||
? scores.reduce((a, b) => a + b, 0) / scores.length
|
|
||||||
: null
|
|
||||||
|
|
||||||
return {
|
|
||||||
roundId: round.id,
|
|
||||||
roundName: round.name,
|
|
||||||
createdAt: round.createdAt,
|
|
||||||
projectCount: projectsByRound.get(round.id) ?? 0,
|
|
||||||
evaluationCount,
|
|
||||||
completionRate,
|
|
||||||
averageScore,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get dashboard stats (optionally scoped to a round)
|
* Get dashboard stats (optionally scoped to a round)
|
||||||
*/
|
*/
|
||||||
@@ -875,61 +789,86 @@ export const analyticsRouter = router({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// For each round, get assignment coverage and evaluation completion
|
// Batch all queries by roundIds to avoid N+1
|
||||||
const roundOverviews = await Promise.all(
|
const roundIds = rounds.map((r) => r.id)
|
||||||
rounds.map(async (round) => {
|
|
||||||
const [
|
|
||||||
projectRoundStates,
|
|
||||||
totalAssignments,
|
|
||||||
completedEvaluations,
|
|
||||||
distinctJurors,
|
|
||||||
] = await Promise.all([
|
|
||||||
ctx.prisma.projectRoundState.groupBy({
|
|
||||||
by: ['state'],
|
|
||||||
where: { roundId: round.id },
|
|
||||||
_count: true,
|
|
||||||
}),
|
|
||||||
ctx.prisma.assignment.count({
|
|
||||||
where: { roundId: round.id },
|
|
||||||
}),
|
|
||||||
ctx.prisma.evaluation.count({
|
|
||||||
where: {
|
|
||||||
assignment: { roundId: round.id },
|
|
||||||
status: 'SUBMITTED',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
ctx.prisma.assignment.groupBy({
|
|
||||||
by: ['userId'],
|
|
||||||
where: { roundId: round.id },
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
const stateBreakdown = projectRoundStates.map((ps) => ({
|
const [
|
||||||
state: ps.state,
|
allProjectRoundStates,
|
||||||
count: ps._count,
|
allAssignmentCounts,
|
||||||
}))
|
allCompletedEvals,
|
||||||
|
allDistinctJurors,
|
||||||
|
] = await Promise.all([
|
||||||
|
ctx.prisma.projectRoundState.groupBy({
|
||||||
|
by: ['roundId', 'state'],
|
||||||
|
where: { roundId: { in: roundIds } },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
ctx.prisma.assignment.groupBy({
|
||||||
|
by: ['roundId'],
|
||||||
|
where: { roundId: { in: roundIds } },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
// groupBy on relation field not supported, use raw count per round
|
||||||
|
ctx.prisma.$queryRaw<{ roundId: string; count: bigint }[]>`
|
||||||
|
SELECT a."roundId", COUNT(e.id)::bigint as count
|
||||||
|
FROM "Evaluation" e
|
||||||
|
JOIN "Assignment" a ON e."assignmentId" = a.id
|
||||||
|
WHERE a."roundId" = ANY(${roundIds}) AND e.status = 'SUBMITTED'
|
||||||
|
GROUP BY a."roundId"
|
||||||
|
`,
|
||||||
|
ctx.prisma.assignment.groupBy({
|
||||||
|
by: ['roundId', 'userId'],
|
||||||
|
where: { roundId: { in: roundIds } },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
const totalProjects = projectRoundStates.reduce((sum, ps) => sum + ps._count, 0)
|
// Build lookup maps
|
||||||
const completionRate = totalAssignments > 0
|
const statesByRound = new Map<string, { state: string; count: number }[]>()
|
||||||
? Math.round((completedEvaluations / totalAssignments) * 100)
|
for (const ps of allProjectRoundStates) {
|
||||||
: 0
|
const list = statesByRound.get(ps.roundId) || []
|
||||||
|
list.push({ state: ps.state, count: ps._count })
|
||||||
|
statesByRound.set(ps.roundId, list)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const assignmentCountByRound = new Map<string, number>()
|
||||||
roundId: round.id,
|
for (const ac of allAssignmentCounts) {
|
||||||
roundName: round.name,
|
assignmentCountByRound.set(ac.roundId, ac._count)
|
||||||
roundType: round.roundType,
|
}
|
||||||
roundStatus: round.status,
|
|
||||||
sortOrder: round.sortOrder,
|
const completedEvalsByRound = new Map<string, number>()
|
||||||
totalProjects,
|
for (const ce of allCompletedEvals) {
|
||||||
stateBreakdown,
|
completedEvalsByRound.set(ce.roundId, Number(ce.count))
|
||||||
totalAssignments,
|
}
|
||||||
completedEvaluations,
|
|
||||||
pendingEvaluations: totalAssignments - completedEvaluations,
|
const jurorCountByRound = new Map<string, number>()
|
||||||
completionRate,
|
for (const j of allDistinctJurors) {
|
||||||
jurorCount: distinctJurors.length,
|
jurorCountByRound.set(j.roundId, (jurorCountByRound.get(j.roundId) || 0) + 1)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
)
|
const roundOverviews = rounds.map((round) => {
|
||||||
|
const stateBreakdown = statesByRound.get(round.id) || []
|
||||||
|
const totalProjects = stateBreakdown.reduce((sum, ps) => sum + ps.count, 0)
|
||||||
|
const totalAssignments = assignmentCountByRound.get(round.id) || 0
|
||||||
|
const completedEvaluations = completedEvalsByRound.get(round.id) || 0
|
||||||
|
const completionRate = totalAssignments > 0
|
||||||
|
? Math.round((completedEvaluations / totalAssignments) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
roundId: round.id,
|
||||||
|
roundName: round.name,
|
||||||
|
roundType: round.roundType,
|
||||||
|
roundStatus: round.status,
|
||||||
|
sortOrder: round.sortOrder,
|
||||||
|
totalProjects,
|
||||||
|
stateBreakdown,
|
||||||
|
totalAssignments,
|
||||||
|
completedEvaluations,
|
||||||
|
pendingEvaluations: totalAssignments - completedEvaluations,
|
||||||
|
completionRate,
|
||||||
|
jurorCount: jurorCountByRound.get(round.id) || 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
competitionId: input.competitionId,
|
competitionId: input.competitionId,
|
||||||
@@ -972,7 +911,7 @@ export const analyticsRouter = router({
|
|||||||
const where: Record<string, unknown> = {}
|
const where: Record<string, unknown> = {}
|
||||||
|
|
||||||
if (input.roundId) {
|
if (input.roundId) {
|
||||||
where.assignments = { some: { roundId: input.roundId } }
|
where.projectRoundStates = { some: { roundId: input.roundId } }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.status) {
|
if (input.status) {
|
||||||
@@ -1370,4 +1309,47 @@ export const analyticsRouter = router({
|
|||||||
allRequirements,
|
allRequirements,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity feed — recent audit log entries for observer dashboard
|
||||||
|
*/
|
||||||
|
getActivityFeed: observerProcedure
|
||||||
|
.input(z.object({ limit: z.number().min(1).max(50).default(10) }).optional())
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const limit = input?.limit ?? 10
|
||||||
|
|
||||||
|
const entries = await ctx.prisma.decisionAuditLog.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
eventType: true,
|
||||||
|
entityType: true,
|
||||||
|
entityId: true,
|
||||||
|
actorId: true,
|
||||||
|
detailsJson: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Batch-fetch actor names
|
||||||
|
const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[]
|
||||||
|
const actors = actorIds.length > 0
|
||||||
|
? await ctx.prisma.user.findMany({
|
||||||
|
where: { id: { in: actorIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
const actorMap = new Map(actors.map((a) => [a.id, a.name]))
|
||||||
|
|
||||||
|
return entries.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
eventType: entry.eventType,
|
||||||
|
entityType: entry.entityType,
|
||||||
|
entityId: entry.entityId,
|
||||||
|
actorName: entry.actorId ? actorMap.get(entry.actorId) ?? null : null,
|
||||||
|
details: entry.detailsJson as Record<string, unknown> | null,
|
||||||
|
createdAt: entry.createdAt,
|
||||||
|
}))
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ export const fileRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdminOrObserver) {
|
||||||
const file = await ctx.prisma.projectFile.findFirst({
|
const file = await ctx.prisma.projectFile.findFirst({
|
||||||
where: { bucket: input.bucket, objectKey: input.objectKey },
|
where: { bucket: input.bucket, objectKey: input.objectKey },
|
||||||
select: {
|
select: {
|
||||||
@@ -283,9 +283,9 @@ export const fileRouter = router({
|
|||||||
roundId: z.string().optional(),
|
roundId: z.string().optional(),
|
||||||
}))
|
}))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdminOrObserver) {
|
||||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||||
ctx.prisma.assignment.findFirst({
|
ctx.prisma.assignment.findFirst({
|
||||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||||
@@ -348,9 +348,9 @@ export const fileRouter = router({
|
|||||||
roundId: z.string(),
|
roundId: z.string(),
|
||||||
}))
|
}))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdminOrObserver) {
|
||||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||||
ctx.prisma.assignment.findFirst({
|
ctx.prisma.assignment.findFirst({
|
||||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||||
@@ -468,9 +468,9 @@ export const fileRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdminOrObserver) {
|
||||||
// Check user has access to the project (assigned or team member)
|
// Check user has access to the project (assigned or team member)
|
||||||
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
|
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||||
ctx.prisma.assignment.findFirst({
|
ctx.prisma.assignment.findFirst({
|
||||||
@@ -652,9 +652,9 @@ export const fileRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdminOrObserver) {
|
||||||
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
|
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||||
ctx.prisma.assignment.findFirst({
|
ctx.prisma.assignment.findFirst({
|
||||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const config: Config = {
|
|||||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./node_modules/@tremor/**/*.{js,ts,jsx,tsx}',
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
container: {
|
container: {
|
||||||
|
|||||||
Reference in New Issue
Block a user