Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
106
src/components/charts/criteria-scores.tsx
Normal file
106
src/components/charts/criteria-scores.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface CriteriaScoreData {
|
||||
id: string
|
||||
name: string
|
||||
averageScore: number
|
||||
count: number
|
||||
}
|
||||
|
||||
interface CriteriaScoresProps {
|
||||
data: CriteriaScoreData[]
|
||||
}
|
||||
|
||||
// Color scale from red to green based on score
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 8) return '#0bd90f' // Excellent - green
|
||||
if (score >= 6) return '#82ca9d' // Good - light green
|
||||
if (score >= 4) return '#ffc658' // Average - yellow
|
||||
if (score >= 2) return '#ff7300' // Poor - orange
|
||||
return '#de0f1e' // Very poor - red
|
||||
}
|
||||
|
||||
export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
||||
const formattedData = data.map((d) => ({
|
||||
...d,
|
||||
displayName:
|
||||
d.name.length > 20 ? d.name.substring(0, 20) + '...' : d.name,
|
||||
}))
|
||||
|
||||
const overallAverage =
|
||||
data.length > 0
|
||||
? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Score by Evaluation Criteria</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Overall Avg: {overallAverage.toFixed(2)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={formattedData}
|
||||
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="displayName"
|
||||
tick={{ fontSize: 11 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
interval={0}
|
||||
height={60}
|
||||
/>
|
||||
<YAxis domain={[0, 10]} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined) => [
|
||||
(value ?? 0).toFixed(2),
|
||||
'Average Score',
|
||||
]}
|
||||
labelFormatter={(_, payload) => {
|
||||
if (payload && payload[0]) {
|
||||
const item = payload[0].payload as CriteriaScoreData
|
||||
return `${item.name} (${item.count} ratings)`
|
||||
}
|
||||
return ''
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="averageScore" radius={[4, 4, 0, 0]}>
|
||||
{formattedData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={getScoreColor(entry.averageScore)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
102
src/components/charts/evaluation-timeline.tsx
Normal file
102
src/components/charts/evaluation-timeline.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Area,
|
||||
ComposedChart,
|
||||
Bar,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface TimelineDataPoint {
|
||||
date: string
|
||||
daily: number
|
||||
cumulative: number
|
||||
}
|
||||
|
||||
interface EvaluationTimelineProps {
|
||||
data: TimelineDataPoint[]
|
||||
}
|
||||
|
||||
export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
||||
// Format date for display
|
||||
const formattedData = data.map((d) => ({
|
||||
...d,
|
||||
dateFormatted: new Date(d.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
}))
|
||||
|
||||
const totalEvaluations =
|
||||
data.length > 0 ? data[data.length - 1].cumulative : 0
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Evaluation Progress Over Time</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Total: {totalEvaluations} evaluations
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={formattedData}
|
||||
margin={{ top: 20, right: 30, bottom: 20, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="dateFormatted"
|
||||
tick={{ fontSize: 12 }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis yAxisId="left" orientation="left" stroke="#8884d8" />
|
||||
<YAxis yAxisId="right" orientation="right" stroke="#82ca9d" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined, name: string | undefined) => [
|
||||
value ?? 0,
|
||||
(name ?? '') === 'daily' ? 'Daily' : 'Cumulative',
|
||||
]}
|
||||
labelFormatter={(label) => `Date: ${label}`}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
yAxisId="left"
|
||||
dataKey="daily"
|
||||
name="Daily Evaluations"
|
||||
fill="#8884d8"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="cumulative"
|
||||
name="Cumulative Total"
|
||||
stroke="#82ca9d"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
135
src/components/charts/geographic-distribution.tsx
Normal file
135
src/components/charts/geographic-distribution.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getCountryName } from '@/lib/countries'
|
||||
import { Globe, MapPin } from 'lucide-react'
|
||||
|
||||
type CountryData = { countryCode: string; count: number }
|
||||
|
||||
type GeographicDistributionProps = {
|
||||
data: CountryData[]
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const LeafletMap = dynamic(() => import('./leaflet-map'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className="flex items-center justify-center bg-muted/30 rounded-md animate-pulse"
|
||||
style={{ height: 400 }}
|
||||
>
|
||||
<Globe className="h-8 w-8 text-muted-foreground/40" />
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
const LeafletMapFull = dynamic(() => import('./leaflet-map'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className="flex items-center justify-center bg-muted/30 rounded-md animate-pulse"
|
||||
style={{ height: 500 }}
|
||||
>
|
||||
<Globe className="h-8 w-8 text-muted-foreground/40" />
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
export function GeographicDistribution({
|
||||
data,
|
||||
compact = false,
|
||||
}: GeographicDistributionProps) {
|
||||
const validData = data.filter((d) => d.countryCode !== 'UNKNOWN')
|
||||
const unknownCount = data
|
||||
.filter((d) => d.countryCode === 'UNKNOWN')
|
||||
.reduce((sum, d) => sum + d.count, 0)
|
||||
const totalProjects = data.reduce((sum, d) => sum + d.count, 0)
|
||||
const countryCount = validData.length
|
||||
|
||||
if (data.length === 0 || totalProjects === 0) {
|
||||
return compact ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Globe className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No geographic data available
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5" />
|
||||
Project Origins
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Globe className="h-12 w-12 text-muted-foreground/30" />
|
||||
<p className="mt-3 text-muted-foreground">
|
||||
No geographic data available yet
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<LeafletMap data={validData} compact />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-1">
|
||||
<span>{countryCount} countries</span>
|
||||
{unknownCount > 0 && (
|
||||
<span>{unknownCount} projects without country</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5" />
|
||||
Project Origins
|
||||
</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{countryCount} countries · {totalProjects} projects
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<LeafletMapFull data={validData} />
|
||||
{unknownCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{unknownCount} project{unknownCount !== 1 ? 's' : ''} without country data
|
||||
</p>
|
||||
)}
|
||||
{/* Top countries table */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Top Countries</p>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
|
||||
{validData
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10)
|
||||
.map((d) => (
|
||||
<div
|
||||
key={d.countryCode}
|
||||
className="flex items-center justify-between py-1 border-b border-border/50"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{getCountryName(d.countryCode)}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">{d.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
50
src/components/charts/geographic-summary-card.tsx
Normal file
50
src/components/charts/geographic-summary-card.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { GeographicDistribution } from './geographic-distribution'
|
||||
import { Globe } from 'lucide-react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
type GeographicSummaryCardProps = {
|
||||
programId: string
|
||||
}
|
||||
|
||||
export function GeographicSummaryCard({ programId }: GeographicSummaryCardProps) {
|
||||
const { data, isLoading } = trpc.analytics.getGeographicDistribution.useQuery(
|
||||
{ programId },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
Project Origins
|
||||
</CardTitle>
|
||||
<CardDescription>Geographic distribution of projects</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-[250px] w-full rounded-md" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
Project Origins
|
||||
</CardTitle>
|
||||
<CardDescription>Geographic distribution of projects</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<GeographicDistribution data={data || []} compact />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
8
src/components/charts/index.ts
Normal file
8
src/components/charts/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { ScoreDistributionChart } from './score-distribution'
|
||||
export { EvaluationTimelineChart } from './evaluation-timeline'
|
||||
export { StatusBreakdownChart } from './status-breakdown'
|
||||
export { JurorWorkloadChart } from './juror-workload'
|
||||
export { ProjectRankingsChart } from './project-rankings'
|
||||
export { CriteriaScoresChart } from './criteria-scores'
|
||||
export { GeographicDistribution } from './geographic-distribution'
|
||||
export { GeographicSummaryCard } from './geographic-summary-card'
|
||||
102
src/components/charts/juror-workload.tsx
Normal file
102
src/components/charts/juror-workload.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface JurorWorkloadData {
|
||||
id: string
|
||||
name: string
|
||||
assigned: number
|
||||
completed: number
|
||||
completionRate: number
|
||||
}
|
||||
|
||||
interface JurorWorkloadProps {
|
||||
data: JurorWorkloadData[]
|
||||
}
|
||||
|
||||
export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
||||
// Truncate names for display
|
||||
const formattedData = data.map((d) => ({
|
||||
...d,
|
||||
displayName: d.name.length > 15 ? d.name.substring(0, 15) + '...' : d.name,
|
||||
}))
|
||||
|
||||
const totalAssigned = data.reduce((sum, d) => sum + d.assigned, 0)
|
||||
const totalCompleted = data.reduce((sum, d) => sum + d.completed, 0)
|
||||
const overallRate =
|
||||
totalAssigned > 0 ? Math.round((totalCompleted / totalAssigned) * 100) : 0
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Juror Workload</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{overallRate}% overall completion
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={formattedData}
|
||||
layout="vertical"
|
||||
margin={{ top: 20, right: 30, bottom: 20, left: 100 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis type="number" />
|
||||
<YAxis
|
||||
dataKey="displayName"
|
||||
type="category"
|
||||
width={90}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined, name: string | undefined) => [
|
||||
value ?? 0,
|
||||
(name ?? '') === 'assigned' ? 'Assigned' : 'Completed',
|
||||
]}
|
||||
labelFormatter={(_, payload) => {
|
||||
if (payload && payload[0]) {
|
||||
const item = payload[0].payload as JurorWorkloadData
|
||||
return `${item.name} (${item.completionRate}% complete)`
|
||||
}
|
||||
return ''
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="assigned"
|
||||
name="Assigned"
|
||||
fill="#8884d8"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="completed"
|
||||
name="Completed"
|
||||
fill="#82ca9d"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
78
src/components/charts/leaflet-map.tsx
Normal file
78
src/components/charts/leaflet-map.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { MapContainer, TileLayer, CircleMarker, Tooltip } from 'react-leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { COUNTRIES } from '@/lib/countries'
|
||||
|
||||
|
||||
type CountryData = { countryCode: string; count: number }
|
||||
|
||||
type LeafletMapProps = {
|
||||
data: CountryData[]
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export default function LeafletMap({ data, compact }: LeafletMapProps) {
|
||||
const markers = useMemo(() => {
|
||||
const maxCount = Math.max(...data.map((d) => d.count), 1)
|
||||
return data
|
||||
.filter((d) => d.countryCode !== 'UNKNOWN' && COUNTRIES[d.countryCode])
|
||||
.map((d) => {
|
||||
const country = COUNTRIES[d.countryCode]
|
||||
const ratio = d.count / maxCount
|
||||
const radius = 5 + ratio * 15
|
||||
return {
|
||||
code: d.countryCode,
|
||||
name: country.name,
|
||||
position: [country.lat, country.lng] as [number, number],
|
||||
count: d.count,
|
||||
radius,
|
||||
ratio,
|
||||
}
|
||||
})
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={[20, 0]}
|
||||
zoom={compact ? 1 : 2}
|
||||
scrollWheelZoom
|
||||
zoomControl
|
||||
dragging
|
||||
doubleClickZoom
|
||||
style={{
|
||||
height: compact ? 400 : 500,
|
||||
width: '100%',
|
||||
borderRadius: '0.5rem',
|
||||
background: '#f0f0f0',
|
||||
}}
|
||||
attributionControl={false}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||
/>
|
||||
{markers.map((marker) => (
|
||||
<CircleMarker
|
||||
key={marker.code}
|
||||
center={marker.position}
|
||||
radius={marker.radius}
|
||||
pathOptions={{
|
||||
color: '#de0f1e',
|
||||
fillColor: '#de0f1e',
|
||||
fillOpacity: 0.35 + marker.ratio * 0.45,
|
||||
weight: 1.5,
|
||||
}}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -marker.radius]}>
|
||||
<div className="text-xs font-medium">
|
||||
<span className="font-semibold">{marker.name}</span>
|
||||
<br />
|
||||
{marker.count} project{marker.count !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</CircleMarker>
|
||||
))}
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
121
src/components/charts/project-rankings.tsx
Normal file
121
src/components/charts/project-rankings.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface ProjectRankingData {
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
status: string
|
||||
averageScore: number | null
|
||||
evaluationCount: number
|
||||
}
|
||||
|
||||
interface ProjectRankingsProps {
|
||||
data: ProjectRankingData[]
|
||||
limit?: number
|
||||
}
|
||||
|
||||
// Generate color based on score (red to green gradient)
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 8) return '#0bd90f' // Excellent - green
|
||||
if (score >= 6) return '#82ca9d' // Good - light green
|
||||
if (score >= 4) return '#ffc658' // Average - yellow
|
||||
if (score >= 2) return '#ff7300' // Poor - orange
|
||||
return '#de0f1e' // Very poor - red
|
||||
}
|
||||
|
||||
export function ProjectRankingsChart({
|
||||
data,
|
||||
limit = 20,
|
||||
}: ProjectRankingsProps) {
|
||||
const displayData = data.slice(0, limit).map((d, index) => ({
|
||||
...d,
|
||||
rank: index + 1,
|
||||
displayTitle:
|
||||
d.title.length > 25 ? d.title.substring(0, 25) + '...' : d.title,
|
||||
score: d.averageScore || 0,
|
||||
}))
|
||||
|
||||
const averageScore =
|
||||
data.length > 0
|
||||
? data.reduce((sum, d) => sum + (d.averageScore || 0), 0) / data.length
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Project Rankings</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Top {displayData.length} of {data.length} projects
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[500px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={displayData}
|
||||
layout="vertical"
|
||||
margin={{ top: 20, right: 30, bottom: 20, left: 150 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis type="number" domain={[0, 10]} />
|
||||
<YAxis
|
||||
dataKey="displayTitle"
|
||||
type="category"
|
||||
width={140}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined) => [(value ?? 0).toFixed(2), 'Average Score']}
|
||||
labelFormatter={(_, payload) => {
|
||||
if (payload && payload[0]) {
|
||||
const item = payload[0].payload as ProjectRankingData & {
|
||||
rank: number
|
||||
}
|
||||
return `#${item.rank} - ${item.title}${item.teamName ? ` (${item.teamName})` : ''}`
|
||||
}
|
||||
return ''
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine
|
||||
x={averageScore}
|
||||
stroke="#666"
|
||||
strokeDasharray="5 5"
|
||||
label={{
|
||||
value: `Avg: ${averageScore.toFixed(1)}`,
|
||||
position: 'top',
|
||||
fill: '#666',
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="score" radius={[0, 4, 4, 0]}>
|
||||
{displayData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={getScoreColor(entry.score)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
92
src/components/charts/score-distribution.tsx
Normal file
92
src/components/charts/score-distribution.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface ScoreDistributionProps {
|
||||
data: { score: number; count: number }[]
|
||||
averageScore: number
|
||||
totalScores: number
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#de0f1e', // 1 - red (poor)
|
||||
'#e6382f',
|
||||
'#ed6141',
|
||||
'#f38a52',
|
||||
'#f8b364', // 5 - yellow (average)
|
||||
'#c9c052',
|
||||
'#99cc41',
|
||||
'#6ad82f',
|
||||
'#3be31e',
|
||||
'#0bd90f', // 10 - green (excellent)
|
||||
]
|
||||
|
||||
export function ScoreDistributionChart({
|
||||
data,
|
||||
averageScore,
|
||||
totalScores,
|
||||
}: ScoreDistributionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Score Distribution</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Avg: {averageScore.toFixed(2)} ({totalScores} scores)
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="score"
|
||||
label={{
|
||||
value: 'Score',
|
||||
position: 'insideBottom',
|
||||
offset: -10,
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
label={{
|
||||
value: 'Count',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined) => [value ?? 0, 'Count']}
|
||||
labelFormatter={(label) => `Score: ${label}`}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
131
src/components/charts/status-breakdown.tsx
Normal file
131
src/components/charts/status-breakdown.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface StatusDataPoint {
|
||||
status: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface StatusBreakdownProps {
|
||||
data: StatusDataPoint[]
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
PENDING: '#8884d8',
|
||||
UNDER_REVIEW: '#82ca9d',
|
||||
SHORTLISTED: '#ffc658',
|
||||
SEMIFINALIST: '#ff7300',
|
||||
FINALIST: '#00C49F',
|
||||
WINNER: '#0088FE',
|
||||
ELIMINATED: '#de0f1e',
|
||||
WITHDRAWN: '#999999',
|
||||
}
|
||||
|
||||
const renderCustomLabel = ({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
percent,
|
||||
}: {
|
||||
cx?: number
|
||||
cy?: number
|
||||
midAngle?: number
|
||||
innerRadius?: number
|
||||
outerRadius?: number
|
||||
percent?: number
|
||||
}) => {
|
||||
if (cx === undefined || cy === undefined || midAngle === undefined ||
|
||||
innerRadius === undefined || outerRadius === undefined || percent === undefined) {
|
||||
return null
|
||||
}
|
||||
if (percent < 0.05) return null // Don't show labels for small slices
|
||||
|
||||
const RADIAN = Math.PI / 180
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN)
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN)
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="white"
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
fontWeight={600}
|
||||
>
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
|
||||
const total = data.reduce((sum, item) => sum + item.count, 0)
|
||||
|
||||
// Format status for display
|
||||
const formattedData = data.map((d) => ({
|
||||
...d,
|
||||
name: d.status.replace(/_/g, ' '),
|
||||
color: STATUS_COLORS[d.status] || '#8884d8',
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Project Status Distribution</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{total} projects
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={formattedData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderCustomLabel}
|
||||
outerRadius={100}
|
||||
innerRadius={50}
|
||||
fill="#8884d8"
|
||||
dataKey="count"
|
||||
nameKey="name"
|
||||
>
|
||||
{formattedData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined, name: string | undefined) => [
|
||||
`${value ?? 0} (${(((value ?? 0) / total) * 100).toFixed(1)}%)`,
|
||||
name ?? '',
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user